Observability in jedem Projekt, Live-Push im Browser, und eine Lektion über Firebase-Configs
Foto: Unsplash
Drei Tage nach der Closed-Alpha-Einladung kommt das, was man als „die zweite Schicht" bezeichnet: nicht neue Features, sondern die Instrumentierung und Zuverlässigkeit, die man braucht, um zu sehen, was passiert, wenn echte Leute auf die App schauen — und um es zu reparieren, bevor es zum Problem wird. Das Ganze war eine Mischung aus fünfzehn neuen Sentry-Projekten, einem komplett neuen Real-Time-Layer, zwei Runden „warum erscheint Seite X als 500", und einer ehrlichen Selbstkorrektur darüber, was eigentlich ein Secret ist und was nicht.
Sentry in jedem Projekt, Clarity + GA4 + Firebase oben drauf
Wir hatten bisher keine Error-Tracking-Schicht außerhalb der Aspire-OpenTelemetry-Exports. Das hat gereicht, solange die App auf meinem Rechner lief; sobald echte Leser-Sessions auf Azure Container Apps treffen, reicht es nicht mehr. Sentry ist jetzt in jedem Service eingebaut — Api, Web, Web-Browser (separates Projekt für den JavaScript-Bundle), die vier KI-Pipelines (Notification / Image / Audio / Content), die vier Webhook-Hosts (Stripe / Apple / Google), die vier Initialization-Worker (Secrets / Account / Data / Products), und die MAUI-App. Fünfzehn logische Services, fünfzehn getrennte Sentry-Projekte.
Jedes Projekt bekommt seinen eigenen DSN, gespeichert im outastory-secrets Key Vault und zur Laufzeit in die Services injiziert. Entwicklungs-/Test-/Produktionsumgebung werden über das Sentry:Environment-Tag unterschieden, nicht über separate Projekte — das hält Release-Health-Views und Alert-Regeln kompakt.
Product-Analytics sitzen daneben:
- Microsoft Clarity auf der Web-App für Session-Replay und Heatmaps. Privacy-first konfiguriert (text maskiert, Medien geblockt); greift nur, wenn der Leser über den CookieYes-Banner dem Analytics-Cookie explizit zustimmt.
- Google Analytics 4 (
G-BDZS2837MZ) mit Consent Mode v2 — alle vier Parameter (ad_storage,ad_user_data,ad_personalization,analytics_storage) stehen vor Zustimmung aufdenied. Klick auf „Akzeptieren" im CookieYes-Banner kippt sie aufgranted; „Ablehnen" oder „Widerrufen" zurück aufdenied. Das ist der EEA-konforme Pfad seit März 2024. - Firebase Analytics auf den Mobile-Apps für Android und iOS, mit dem gleichen opt-in-Pattern. Auf iOS wird bei der ersten Analytics-Zustimmung Apples ATT-Dialog eingeblendet — wer „Ask App Not to Track" wählt, bleibt bei Sentry-Crashes allein (legitimes Interesse), aber Firebase bleibt aus.
- Measurement Protocol für serverseitige Events: wenn der Stripe-Webhook ein Subscription-Purchase bestätigt, kann der Backend dieses Event explizit an die Firebase-Analytics-Property schicken, attribuiert auf den mobilen Nutzer. Das ist ein separates Secret; die
api_key-Werte in den Firebase-Config-Dateien sind keine Secrets (dazu unten mehr).
Live-Benachrichtigungen via Azure SignalR
Die Notifications-Tabelle bekam in der vorletzten Woche ihren eigenen Poller: alle 45 Sekunden holt sich die Web-App die neuen Rows und zeigt sie als Toast an. Das ist korrekt, aber fühlt sich träge an. Echte Live-Notifications brauchen einen Push-Kanal — und das ist gleichzeitig der Moment, an dem Azure SignalR Service sinnvoll wird.
Zwei Dinge gleichzeitig gelöst:
- Blazor-Server-Circuits werden in Publish-Mode auf Azure SignalR ausgelagert. Ohne diesen Schritt hängt jeder signed-in Leser an einem stickigen WebSocket an genau einem Web-Container-App-Replica — Horizontal Scaling wird zum Roulette. Mit Azure SignalR als Backplane wird der Web-Host zustandslos für Circuits; wir können Replicas rein und raus schieben, ohne dass ein offener Reader sein Scroll-Position verliert.
- Ein eigener
NotificationsHub([Authorize], pro-User-Group keyed on Auth0-Sub) hängt neben den Blazor-Circuits am selben Azure-SignalR-Service. Wenn derNotificationProcessor(die Azure-Function, die Service-Bus-Events in Notifications umsetzt) einen neuen Row schreibt, dispatcht er zusätzlich einen Live-Event direkt an die Gruppeuser:{sub}. Jeder offene Tab des Lesers fängt den Event via Browser-SignalR-Client ab und zeigt sofort einen Toast — ohne auf den 45-Sekunden-Poller zu warten.
Der 45s-Poller ist bewusst nicht gelöscht worden. Er bleibt als Fallback: wenn Azure SignalR einen Ausfall hat, wenn der Leser in einem restriktiven Netzwerk ist, wenn der Browser-Client aus irgendeinem Grund die Verbindung nicht hält — der Poller bringt die Notification trotzdem durch. Wenn der Hub aktiv ist, fällt der Poller automatisch auf ein 5-Minuten-Intervall zurück, um die Quota zu schonen; sobald der Hub stillsteht, geht er zurück auf 45 Sekunden.
Lokal (Aspire Testing, Dev-Boxes ohne Azure-SignalR-Account) fällt die ganze Sache per builder.Configuration.GetConnectionString(...)-Check auf die native In-Process-WebSocket-Transport zurück. Der NotificationProcessor bekommt einen NullLiveNotificationDispatcher, der DispatchAsync silent no-opt-et. Kein Code-Pfad verlangt, dass du einen Azure-SignalR-Account für dotnet run --project AppHost hast.
Drei Regressionen, die Playwright gefunden hat — und was sie uns beigebracht haben
Die Testsuite fing nach dem ersten Sentry-Rollout mit 27 von 288 Playwright-Tests an zu scheitern. Drei voneinander getrennte Fehler, alle mit derselben Fallhöhe: wenn man sie einzeln anschaut, sehen sie wie „flaky CI-Tests" aus; wenn man sie in Kette sieht, sind es echte Production-Bugs.
Foto: Unsplash
/ und /subscription gaben 500
Anonymer Besuch der Home-Seite oder der Subscription-Seite als signed-in User lieferte HTTP 500. Home-Tests in derselben Test-Suite gingen gleichzeitig durch — das war also kein globaler Breakage, sondern spezifische Code-Pfade.
Root Cause: OutaStoryApiClient hatte fünf verschiedene GetFromJsonAsync-Aufrufe (stories, authors, last-chance, categories, subscription-plans), die HttpRequestException ungefangen aus OnInitializedAsync im SSR-Prerender fliegen ließen. Ein API-Hiccup → Exception → Blazor's SSR-Pipeline gibt 500 zurück. Fix: try/catch um jeden einzelnen Call, Rückgabe einer leeren Collection im Fehlerfall. Matches das defensive Pattern, das GetAudioStoriesAsync / GetSubscriptionStatusAsync schon hatten.
Erste Runde: fünf Methoden. Zweite Runde (als die nächsten Tests dieselbe Klasse anderer Routen trafen): neun weitere Methoden (GetHelpTopicsAsync, GetAuthorByUserIdAsync, GetStoriesByAuthorAsync, GetStoriesAsync, GetNotificationsAsync, GetPromotionsAsync, GetCommentsAsync, SearchStoriesAsync, SearchAsync). Was ich dabei gelernt habe: einmal „defensiv" anfangen und dann das ganze Interface so machen, nicht „fix jeden Fall einzeln, wenn er sich zeigt". Die 5+9-Aufteilung war kein Plan, sondern zwei Reactionen.
Die deutsche Sprache „rutschte" über Requests hinweg
LocalizationTests.CultureCookie_German_* scheiterten mit html[lang]="en", obwohl der .AspNetCore.Culture-Cookie klar de-DE sagte. Zuerst sah das wie ein Client-Side-Bug aus: die culture.js schrieb den Cookie auf Basis von navigator.language um — und headless Chromium meldet „en-US", egal was serverseitig steht. Fix: Lese-Reihenfolge in getCulture() wurde auf localStorage → .AspNetCore.Culture-Cookie → navigator.language geändert; im Cookie-Branch wird der Cookie nicht zurückgeschrieben (der Server ist dort autoritativ).
Aber das erklärte nicht alles. Eine zweite Runde dieser Tests scheiterte sporadisch auch auf neuen Culture-Cookies. Die Ursache: CultureService.ApplyCulture hatte CultureInfo.DefaultThreadCurrentCulture und DefaultThreadCurrentUICulture gesetzt. Das sind prozess-weite Statics. Eine deutsche Circuit schrieb de-DE in den Thread-Pool-Default; der nächste Request (englischer Cookie) wurde auf einem recycelten Thread bedient, dessen Default noch de-DE war — und Home.razor berechnete seine Code-Pfade anhand dieses Defaults. Ergebnis: der Home-Page-Code sprang in einen ungetesteten Zweig, der eine NullReferenceException warf, die als 500 rauskam.
Der Fix war ein kleiner Diff — nur CurrentCulture / CurrentUICulture setzen (async-lokal, pro logischem Thread), niemals DefaultThreadCurrent*. Aber die Diagnose war teurer als der Fix: der Bug hat sich als „Home ist manchmal 500" getarnt und erst nach dem Kombinieren von zwei Playwright-Test-Klassen konsistent reproduziert.
ProfileCompletion-Validierung ließ leere Felder durch
ProfileCompletionTests.Validation_EmptyFirstName_* ging durch die Form mit leerem First-Name und wurde danach auf /home weitergeleitet, ohne dass die Client-Side-Validierung zuschlug. Im Log: „waiting for [data-testid='account-complete-error-firstname'] → timeout, navigated to https://localhost/home."
Sieht aus wie ein Validation-Bug, war aber ein Test-Infrastruktur-Bug. Die Test-Helper ResetCompletionStateAsync schickte einen fetch('/api/profile/_e2e-reset')-Call aus dem Browser heraus — und traf damit den Web-Host, nicht den Api-Host. Weil der Web-Host keinen Reverse-Proxy für /api/* hat, 404'te der Call stillschweigend. Die Profile blieb im „complete"-Zustand aus einem vorherigen Happy-Path-Test, die Form wurde mit „E2E" / „Tester" vorgefüllt, der „skip firstname"-Zweig im Test hat den vorgefüllten Wert gar nicht geleert, die Validierung lief korrekt gegen einen gefüllten Wert, und die Form wurde korrekt submittet.
Fix: ResetCompletionStateAsync ruft Fixture.ApiBaseUrl + /api/profile/_e2e-reset mit dem X-E2e-Test-User-Header direkt über HttpClient auf, wie alle anderen signed-in Playwright-Tests es tun. Plus vier neue bunit-Tests, die denselben „Server hat Werte vorgefüllt"-Fall direkt auf Komponenten-Ebene abprüfen und in 50 ms scheitern, nicht nach 15 Sekunden Timeout. Die Art Regression, die man einmal durchsteht und danach nie wieder durchsteht, weil die Tests zu schnell sind.
Die Lektion über Firebase-Configs
Parallel zur Observability-Arbeit gab es einen absurden kleinen Zickzack-Weg in der MAUI-Release-Pipeline. Erste Version: google-services.json und GoogleService-Info.plist mit REPLACE_ME-Sentinels im Repo committen, lokale Skripte überschreiben das, CI dekodiert eine Base64-Version aus GitHub Secrets. Feste Überzeugung dabei: die realen Firebase-Configs sind sensibel und dürfen nicht im Repo liegen.
Das hat funktioniert, bis die ersten CI-Läufe auf Android und iOS prompt mit „Bundle Resource not found" und „Failed to Read GoogleServicesJson" gescheitert sind, weil meine BeforeBuild-Targets die Template-Dateien zu spät an den richtigen Ort kopierten — Xamarin's ProcessGoogleServicesJson hatte sich schon ans Evaluate-Phase gehängt. Der Workaround mit --skip-worktree und Base64-CI-Decoding war... funktional, aber unelegant.
Der Moment, in dem ich die Sache noch einmal recherchiert habe, hat den Ursprungsglauben umgeworfen. Google sagt selbst (https://firebase.google.com/docs/projects/api-keys):
„Firebase API keys are different from typical API keys. Unlike how API keys are typically used, API keys for Firebase services are not used to control access to backend resources; [...] API keys for Firebase services are ok to include in code or checked-in config files."
Access-Control auf Firebase Mobile-Services läuft über den Android-Package-Name + SHA-Signier-Fingerabdruck und über den iOS-Bundle-Identifier — nicht über den API-Key. Dein google-services.json liegt ohnehin in jeder ausgelieferten APK. Es ist kein Secret; es ist ein Projekt-Identifier. Das Schützen war ein Reflex, kein durchdachtes Threat-Model.
Also: Template-Renaming rückgängig gemacht, Decode-Schritte aus release.yml entfernt, --skip-worktree-Logik rausgeworfen, die SEED_FIREBASE_*_B64-GitHub-Secrets stehen als Orphans da (löschbar), das Firebase-Sync-Skript ist von ~450 auf ~250 Zeilen geschrumpft. Alles genau so committet, wie Google es empfiehlt.
Was wirklich secret ist — der Measurement Protocol API Secret für Backend-zu-GA4-Events — liegt weiterhin separat im Key Vault und in GitHub Secrets. Der Unterschied liegt nicht in der Dateigröße, sondern in der Zugriffskontrolle des Endpunkts.
PowerShell-Provisioning, damit es in zwei Jahren noch stimmt
Das ganze oben lebt auf einem Stapel Secrets — fünfzehn Sentry-DSNs, drei Firebase-Werte, zwei GA4-Werte, plus die bereits bestehenden Stripe/OneSignal/IAP/Auth0-Credentials. Lokal sind das „AppHost user-secrets" (dotnet user-secrets set); in Produktion leben sie im outastory-secrets Key Vault, injected über Initialization.Secrets.
Das Muster war schon da, aber die neuen Integrationen haben es ausgeweitet:
tools/sentry/Provision-SentryProjects.ps1— fünfzehn Sentry-Projekte anlegen, DSNs holen, auf AppHost-user-secrets und GitHub-Secrets pushen, und Alert-Rules provisionieren (Teams für High-Priority-Issues, drei GitHub-Ticket-Rules pro Projekt — eine pro Environment mit[Development|Test|Production]-Prefix im Ticket-Titel). Idempotent, zustandsgetreu, mit-ReplaceAlertRules-Schalter für Re-Provisioning.tools/sentry/Provision-SentryUptimeMonitors.ps1— zwei Uptime-Monitore (Api + Web) mit Teams-Benachrichtigung, parametrisierbare URLs (derzeit auf die Azure-Container-App-FQDNs; nach DNS-Cutover aufapi.outastory.com/www.outastory.comumstellbar).tools/google-analytics/Sync-GoogleAnalyticsSecrets.ps1— Measurement-ID und Measurement-Protocol-API-Secret pushen, Config-State inconfig/state.json(gitignored).tools/firebase/Sync-FirebaseSecrets.ps1— extrahiert Android- und iOS-App-IDs aus den committed Config-Dateien, pusht sie plus den optionalen Measurement-Protocol-Secret. Config-Dateien selbst werden nicht mehr kopiert (siehe oben).
release.yml wurde durchgeputzt, um dieselben Env-Vars wie der lokale Flow zu transportieren: 44 Secrets__*-Zeilen pro azd provision und pro azd deploy (diff-identisch zwischen beiden, um Drift zu vermeiden), gegliedert mit Kommentar-Abschnitten pro Provider. Vorher landeten OneSignal / IAP / SendGrid-Werte nur in GitHub-Secrets und nicht in Azure; jetzt fließen sie durch bis in den Produktions-Key-Vault.
Ein echter Fundstück dabei: die MS-Teams-Integration hatte in unserer Sentry-Org zwei Installs — eine unter dem Namen „OutaStory Team" (das gewünschte Ziel) und eine unter „KN-CMT Team" (ein separater Install, Relikt aus einer anderen Arbeit). Get-Integration hat nach Provider-Key gefiltert und dann blind [0] gepickt — was zufällig KN-CMT war. Zwei Tage lang gingen sämtliche „Teams"-Alerts in einen fremden Channel. Sentry's Browser-UI hat keine Warnung ausgegeben, die Rules sahen konfiguriert aus, es fehlte nur der Teams-Bot in der Zielkanal. Fix: -NameFilter an Get-Integration, exaktes Matching gegen $MsTeamsTeamName, und lauter Abbruch, wenn kein Match gefunden wird, mit Auflistung der Available-Names. Sehr viel lieber „Script bricht ab, Operator muss wählen" als „Script pickt irgendein Install und schweigt".
Android-Permissions, iOS-ATT, und der Privacy-Label-Marathon
Nebenschauplatz, aber auflaufender Feinschliff für die Store-Einreichung:
AndroidManifest.xml:com.google.android.gms.permission.AD_IDhinzugefügt (Firebase Analytics braucht sie auf Android 13+). Mit Kommentar, wie man sie viatools:node="remove"wieder rauswirft, falls wir IDFA-frei bleiben wollen.iOS/Info.plist:NSUserTrackingUsageDescriptionmit erklärendem Text hinzugefügt. Apple rejected Binärdateien, dieATTrackingManagerlinken, ohne dass die Usage-Description steht (ITMS-91051 / 91053). UnserFirebaseAnalyticsService.GrantAsyncruft ATT auf; ohne den Key würde die nächste TestFlight-Einreichung zurückbestrahlt werden.- App Store Connect Privacy Labels und Google Play Data Safety — beide Seiten haben jetzt eine komplette Dokumentation in
docs/OBSERVABILITY.md, welche Datentypen unter welcher Kategorie deklariert werden müssen. „Not shared, not linked to user, analytics purpose" für Firebase + Sentry; „App Functionality" für Sentry-Crashes. Tracking: nein — wir verfolgen keinen Leser über andere Apps / Websites.
Das war der Teil der Arbeit, der nichts Spektakuläres produziert, aber nicht getan, heißt: der nächste Release-Build scheitert am Store-Eingangsportier.
Zahlen zum Stand
Unit-Tests: 1.872 grün, 0 rot (vor der Observability-Arbeit: ca. 1.660; 90 neue Tests kamen rein — 53 Options-Tests, 24 Secret-Seed-Tests, 13 bunit für die App.razor-Analytics-Bootstrap, plus je 7 Dispatcher- und Backoff-Tests). Playwright-Suite: 261 bestanden / 27 scheiterten im ersten Run, davon nach zwei Rounds Fix-und-Rerun 278 / 13 und schließlich nach der Profile-Completion-Infrastruktur-Korrektur erwartet grün (ich habe die letzte Runde noch nicht durchlaufen lassen).
Commits in dieser Arbeitswoche: ~30 zwischen SignalR-Rollout, Observability-Foundation, Backend-Wave, Web-Wave, MAUI-Wave, Test-Wave, den drei Regressionsfixes, der Firebase-Simplifikation und dem Teams-Integration-Drama. Die meisten Arbeit hat sich in den zwei „Oh" und dem einen „Aha" konzentriert, nicht in den zehn „fertig".
Was als nächstes
Push ist jetzt fällig — die 32 lokalen Commits wollen auf origin/main, damit die Release-Pipeline mit dem neuen Env-Var-Stack und den neuen Sentry-DSNs läuft. Danach kommt, was schon in der Alpha-Einladung skizziert war: die Alpha-Tester (Web, Android, bald iOS) geben das nächste Prioritäten-Signal. Bis dahin ist der Teil fertig, der „was passiert, wenn etwas schief geht?" beantwortet.
