Mirror — see github.com/blacksky-algorithms/blacksky.community
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add OAuth session lifecycle telemetry to growthbook

Subscribes to the OAuth client's deleted/updated events and wraps the
client's fetch so every /oauth/token round-trip is observed. Emits four
event types to growthbook for diagnosing silent OAuth sign-outs:

- oauth:sessionRefreshed: successful refresh
- oauth:refreshFailed: any /oauth/token failure (network, 5xx, server
rejection); use_dpop_nonce retries are intentionally suppressed since
they're expected control flow, not failures
- oauth:sessionDeleted: client gave up and removed the session locally;
this is the silent-logout signal that was missing before
- oauth:sessionResumeFailed: resumeSession() threw during AppBoot or
SwitchAccount

Each event carries a coarse errorCategory (sessionDeleted | sessionExpired
| invalidGrant | databaseClosed | dpopSkew | dpopOther | refreshExhausted
| subMismatch | timeout | network | serverError | unknown) so we can
filter by failure mode without parsing message strings.

Pre-Provider events are queued and drained when the analytics sink
registers, so failures during BrowserOAuthClient.init() at boot aren't
dropped. Listeners are guarded with a Symbol so HMR re-running the
module can't double-attach.

Adds a dev-only "Force OAuth token refresh" button in Settings >
DevOptions so failure modes can be reproduced deterministically without
waiting for an access token to expire.

Also removes a few pre-existing unused imports/vars in Settings.tsx and
adds an eslint-disable for the dev-only window.agent assignment in
state/session/index.tsx so the pre-commit hook passes.

+341 -6
+57
src/analytics/metrics/types.ts
··· 35 35 | 'AgeAssuranceNoAccessScreen' 36 36 scope: 'current' | 'every' 37 37 } 38 + // OAuth session lifecycle. These are silent client-side events used to 39 + // diagnose unexpected sign-outs (refresh races, IndexedDB eviction, DPoP 40 + // skew, etc.). `account:loggedOut` is reserved for user-initiated logouts. 41 + 'oauth:sessionDeleted': { 42 + // Reason the underlying OAuth client deleted the local session. 43 + // Common values map to oauth-client/session-getter causes: 44 + // 'session_deleted_by_another_process' — refresh race / cross-tab 45 + // 'invalid_grant' — refresh token rejected 46 + // 'database_closed' — IndexedDB unavailable / evicted 47 + // 'unknown' — unmapped cause 48 + cause: string 49 + // Truncated error message for further triage in OpenSearch. 50 + message?: string 51 + } 52 + 'oauth:sessionRefreshed': {} 53 + // Fired when a refresh round-trip to /oauth/token fails for any reason — 54 + // network blocked, timeout, 5xx, server-side rejection. The session may or 55 + // may not survive (a 400 invalid_grant will additionally fire 56 + // oauth:sessionDeleted; a network blip will not). 57 + 'oauth:refreshFailed': { 58 + triggerContext: 'background' | 'debugButton' 59 + errorCategory: 60 + | 'sessionDeleted' 61 + | 'sessionExpired' 62 + | 'invalidGrant' 63 + | 'databaseClosed' 64 + | 'dpopSkew' 65 + | 'dpopOther' 66 + | 'refreshExhausted' 67 + | 'subMismatch' 68 + | 'timeout' 69 + | 'network' 70 + | 'serverError' 71 + | 'unknown' 72 + // Truncated for triage in OpenSearch / postgres. 73 + message?: string 74 + // HTTP status when the failure was a non-2xx response (vs. a thrown fetch). 75 + httpStatus?: number 76 + } 77 + 'oauth:sessionResumeFailed': { 78 + // Where in the app the resume attempt happened. 79 + logContext: 'AppBoot' | 'ChooseAccountForm' | 'SwitchAccount' 80 + // Coarse error category derived from the thrown error string. 81 + errorCategory: 82 + | 'sessionDeleted' 83 + | 'sessionExpired' 84 + | 'invalidGrant' 85 + | 'databaseClosed' 86 + | 'dpopSkew' 87 + | 'dpopOther' 88 + | 'refreshExhausted' 89 + | 'subMismatch' 90 + | 'timeout' 91 + | 'network' 92 + | 'unknown' 93 + message?: string 94 + } 38 95 'notifications:openApp': { 39 96 reason: NotificationReason 40 97 causedBoot: boolean
+30 -4
src/screens/Settings/Settings.tsx
··· 23 23 import {useModerationOpts} from '#/state/preferences/moderation-opts' 24 24 import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' 25 25 import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' 26 - import {useAgent} from '#/state/session' 27 26 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 27 + import { 28 + getWebOAuthClient, 29 + markNextRefreshContext, 30 + } from '#/state/session/oauth-web-client' 28 31 import {useOnboardingDispatch} from '#/state/shell' 29 32 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 30 33 import {useCloseAllActiveElements} from '#/state/util' ··· 33 36 import * as SettingsList from '#/screens/Settings/components/SettingsList' 34 37 import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' 35 38 import {AvatarStackWithFetch} from '#/components/AvatarStack' 36 - import {Button, ButtonText} from '#/components/Button' 37 39 import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist' 38 40 import {useDialogControl} from '#/components/Dialog' 39 41 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' ··· 60 62 import {Loader} from '#/components/Loader' 61 63 import * as Menu from '#/components/Menu' 62 64 import * as Prompt from '#/components/Prompt' 65 + import * as ToastV2 from '#/components/Toast' 63 66 import {Text} from '#/components/Typography' 64 67 import {useFullVerificationState} from '#/components/verification' 65 68 import { ··· 68 71 } from '#/components/verification/VerificationCheckButton' 69 72 import {useAnalytics} from '#/analytics' 70 73 import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' 71 - import {device, useStorage} from '#/storage' 72 74 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 73 75 74 76 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> ··· 379 381 380 382 function DevOptions() { 381 383 const {_} = useLingui() 382 - const agent = useAgent() 384 + const {currentAccount} = useSession() 383 385 const onboardingDispatch = useOnboardingDispatch() 384 386 const navigation = useNavigation<NavigationProp>() 385 387 const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration() ··· 462 464 label={_(msg`Open moderation debug page`)}> 463 465 <SettingsList.ItemText> 464 466 <Trans>Debug Moderation</Trans> 467 + </SettingsList.ItemText> 468 + </SettingsList.PressableItem> 469 + <SettingsList.PressableItem 470 + onPress={async () => { 471 + if (!currentAccount?.isOauthSession) { 472 + ToastV2.show(_(msg`Current account is not an OAuth session`), { 473 + type: 'warning', 474 + }) 475 + return 476 + } 477 + markNextRefreshContext('debugButton') 478 + try { 479 + await getWebOAuthClient().restore(currentAccount.did, true) 480 + ToastV2.show(_(msg`OAuth refresh succeeded`), {type: 'success'}) 481 + } catch (e) { 482 + const msgText = e instanceof Error ? e.message : String(e) 483 + ToastV2.show(_(msg`OAuth refresh failed: ${msgText}`), { 484 + type: 'error', 485 + }) 486 + } 487 + }} 488 + label={_(msg`Force OAuth token refresh`)}> 489 + <SettingsList.ItemText> 490 + <Trans>Force OAuth token refresh</Trans> 465 491 </SettingsList.ItemText> 466 492 </SettingsList.PressableItem> 467 493 <SettingsList.PressableItem
+19 -1
src/state/session/index.tsx
··· 29 29 oauthCreateAgent, 30 30 oauthResumeSession, 31 31 } from './oauth-agent' 32 + import {categorizeOauthError, setOauthTelemetrySink} from './oauth-telemetry' 32 33 import {type Action, getInitialState, reducer, type State} from './reducer' 33 34 export {isSignupQueued} from './util' 34 35 import {addSessionDebugLog} from './logging' ··· 263 264 account: persisted.PersistedAccount 264 265 } 265 266 if (storedAccount.isOauthSession) { 266 - agentAccount = await oauthResumeSession(storedAccount) 267 + try { 268 + agentAccount = await oauthResumeSession(storedAccount) 269 + } catch (e) { 270 + ax.metric('oauth:sessionResumeFailed', { 271 + logContext: isSwitchingAccounts ? 'SwitchAccount' : 'AppBoot', 272 + errorCategory: categorizeOauthError(e), 273 + message: (e instanceof Error ? e.message : String(e)).slice(0, 200), 274 + }) 275 + throw e 276 + } 267 277 } else { 268 278 agentAccount = await createAgentAndResume( 269 279 storedAccount, ··· 323 333 [store, cancelPendingTask], 324 334 ) 325 335 useEffect(() => { 336 + setOauthTelemetrySink(event => { 337 + ax.metric(event.type, event.payload) 338 + }) 339 + return () => setOauthTelemetrySink(null) 340 + }, [ax]) 341 + 342 + useEffect(() => { 326 343 return persisted.onUpdate('session', nextSession => { 327 344 const synced = nextSession 328 345 addSessionDebugLog({type: 'persisted:receive', data: synced}) ··· 385 402 ) 386 403 387 404 // @ts-expect-error window type is not declared, debug only 405 + // eslint-disable-next-line react-hooks/immutability 388 406 if (__DEV__ && IS_WEB) window.agent = state.currentAgentState.agent 389 407 390 408 const agent = state.currentAgentState.agent as BskyAppAgent
+120
src/state/session/oauth-telemetry.ts
··· 1 + import {type Metrics} from '#/analytics/metrics' 2 + 3 + type OauthTelemetryEvent = 4 + | {type: 'oauth:sessionDeleted'; payload: Metrics['oauth:sessionDeleted']} 5 + | {type: 'oauth:sessionRefreshed'; payload: Metrics['oauth:sessionRefreshed']} 6 + | {type: 'oauth:refreshFailed'; payload: Metrics['oauth:refreshFailed']} 7 + | { 8 + type: 'oauth:sessionResumeFailed' 9 + payload: Metrics['oauth:sessionResumeFailed'] 10 + } 11 + 12 + export type OauthTelemetrySink = (event: OauthTelemetryEvent) => void 13 + 14 + let sink: OauthTelemetrySink | null = null 15 + const pending: OauthTelemetryEvent[] = [] 16 + 17 + // The browser OAuth client is constructed once at module load, before the 18 + // React Provider mounts. Until the Provider registers a sink, events are 19 + // queued in memory so we don't drop early-boot signals (e.g. a failed 20 + // session restore that happens during initial init()). 21 + export function setOauthTelemetrySink(next: OauthTelemetrySink | null) { 22 + sink = next 23 + if (sink && pending.length) { 24 + const drained = pending.splice(0, pending.length) 25 + for (const event of drained) sink(event) 26 + } 27 + } 28 + 29 + export function emitOauthTelemetry(event: OauthTelemetryEvent) { 30 + if (sink) { 31 + sink(event) 32 + } else { 33 + pending.push(event) 34 + // Bound the queue so a stuck sink can't grow memory unbounded. 35 + if (pending.length > 50) pending.shift() 36 + } 37 + } 38 + 39 + export function truncateOauthMessage(err: unknown): string | undefined { 40 + let str: string 41 + if (err instanceof Error) { 42 + str = err.message 43 + } else if (typeof err === 'string') { 44 + str = err 45 + } else if (err && typeof err === 'object' && 'message' in err) { 46 + str = String((err as {message: unknown}).message) 47 + } else { 48 + return undefined 49 + } 50 + return str.slice(0, 200) 51 + } 52 + 53 + export function categorizeOauthError( 54 + err: unknown, 55 + ): Metrics['oauth:sessionResumeFailed']['errorCategory'] { 56 + let str: string 57 + if (err instanceof Error) { 58 + str = err.message 59 + } else if (typeof err === 'string') { 60 + str = err 61 + } else if (err && typeof err === 'object' && 'message' in err) { 62 + str = String((err as {message: unknown}).message) 63 + } else { 64 + str = '' 65 + } 66 + if ( 67 + str.includes('session was deleted by another process') || 68 + str.includes('The session was revoked') 69 + ) { 70 + return 'sessionDeleted' 71 + } 72 + // PDS oauth-provider returns `invalid_grant: Session expired` when the 73 + // session age exceeds the public/confidential client lifetime. The OAuth 74 + // client surfaces this as Error('Session expired') and follows up by 75 + // deleting the local session. 76 + if ( 77 + str.includes('Session expired') || 78 + (str.includes('invalid_grant') && str.includes('Session expired')) 79 + ) { 80 + return 'sessionExpired' 81 + } 82 + if ( 83 + str.includes('Database closed') || 84 + str.includes('Database has been disposed') 85 + ) { 86 + return 'databaseClosed' 87 + } 88 + if (str.includes('invalid_dpop_proof') && str.includes('iat claim')) { 89 + return 'dpopSkew' 90 + } 91 + if (str.includes('invalid_dpop_proof')) { 92 + return 'dpopOther' 93 + } 94 + if (str.includes('No refresh token available')) { 95 + return 'refreshExhausted' 96 + } 97 + if ( 98 + str.includes('Token set sub mismatch') || 99 + str.includes('Stored session sub mismatch') 100 + ) { 101 + return 'subMismatch' 102 + } 103 + if (str.includes('timed out') || str.includes('Session resume timed out')) { 104 + return 'timeout' 105 + } 106 + if ( 107 + str.includes('Failed to fetch') || 108 + str.includes('Network request failed') || 109 + str.includes('Load failed') || 110 + // Firefox raw fetch failure when network is offline / URL is blocked 111 + str.includes('NetworkError when attempting to fetch') 112 + ) { 113 + return 'network' 114 + } 115 + // Generic invalid_grant catch-all that isn't a known sub-case above. 116 + if (str.includes('invalid_grant')) { 117 + return 'invalidGrant' 118 + } 119 + return 'unknown' 120 + }
+115 -1
src/state/session/oauth-web-client.ts
··· 1 1 import {BrowserOAuthClient} from '@atproto/oauth-client-browser' 2 2 3 + import {logger} from '#/logger' 4 + import { 5 + categorizeOauthError, 6 + emitOauthTelemetry, 7 + truncateOauthMessage, 8 + } from '#/state/session/oauth-telemetry' 9 + import {type Metrics} from '#/analytics/metrics' 10 + 3 11 const OAUTH_BASE_URL: string = 4 12 process.env.EXPO_PUBLIC_OAUTH_BASE_URL || 'https://blacksky.community' 5 13 ··· 20 28 ) 21 29 } 22 30 23 - const BSKY_OAUTH_CLIENT = createWebOAuthClient() 31 + // Marker symbol so HMR re-running this module can't attach two copies of 32 + // the listeners to the same client instance. 33 + const TELEMETRY_ATTACHED = Symbol.for('blacksky.oauthTelemetryAttached') 34 + 35 + function attachOauthTelemetry(client: BrowserOAuthClient) { 36 + const tagged = client as unknown as Record<symbol, true> 37 + if (tagged[TELEMETRY_ATTACHED]) return 38 + tagged[TELEMETRY_ATTACHED] = true 39 + client.addEventListener('deleted', event => { 40 + const detail = (event as CustomEvent<{sub: string; cause?: unknown}>).detail 41 + const cause = categorizeOauthError(detail.cause) 42 + const message = 43 + detail.cause instanceof Error 44 + ? detail.cause.message 45 + : typeof detail.cause === 'string' 46 + ? detail.cause 47 + : undefined 48 + logger.warn('oauth: session deleted', {sub: detail.sub, cause, message}) 49 + emitOauthTelemetry({ 50 + type: 'oauth:sessionDeleted', 51 + payload: {cause, message: message?.slice(0, 200)}, 52 + }) 53 + }) 54 + client.addEventListener('updated', () => { 55 + emitOauthTelemetry({type: 'oauth:sessionRefreshed', payload: {}}) 56 + }) 57 + } 58 + 59 + // Lets the debug button mark the next refresh attempt as user-initiated so 60 + // the fetch wrapper can label its telemetry accordingly. Cleared after the 61 + // next /oauth/token round-trip completes. 62 + let pendingRefreshContext: Metrics['oauth:refreshFailed']['triggerContext'] = 63 + 'background' 64 + export function markNextRefreshContext( 65 + context: Metrics['oauth:refreshFailed']['triggerContext'], 66 + ) { 67 + pendingRefreshContext = context 68 + } 69 + 70 + // Wraps the global fetch so we can observe every OAuth-provider round trip 71 + // without depending on the OAuth client's higher-level `deleted`/`updated` 72 + // events. Notably this captures network failures and transient 5xx responses 73 + // that don't cause the client to delete the session — exactly the class of 74 + // failures the higher-level events miss. 75 + const oauthInstrumentedFetch: typeof fetch = async (input, init) => { 76 + const url = 77 + typeof input === 'string' 78 + ? input 79 + : input instanceof URL 80 + ? input.href 81 + : input.url 82 + const method = 83 + init?.method?.toUpperCase() ?? 84 + (input instanceof Request ? input.method.toUpperCase() : undefined) 85 + const isTokenEndpoint = method === 'POST' && url.includes('/oauth/token') 86 + if (!isTokenEndpoint) { 87 + return fetch(input, init) 88 + } 89 + const triggerContext = pendingRefreshContext 90 + pendingRefreshContext = 'background' 91 + try { 92 + const res = await fetch(input, init) 93 + if (!res.ok) { 94 + // Try to parse the OAuth error response body for categorization. 95 + // Clone so we don't consume the body the OAuth client will read next. 96 + let errorBody: string | undefined 97 + try { 98 + errorBody = await res.clone().text() 99 + } catch {} 100 + // `use_dpop_nonce` is the server's standard request to retry the call 101 + // with a freshly-issued DPoP nonce — it's expected control flow at the 102 + // start of every refresh, not a failure. Skip it. 103 + if (errorBody?.includes('use_dpop_nonce')) { 104 + return res 105 + } 106 + const errorCategory: Metrics['oauth:refreshFailed']['errorCategory'] = 107 + res.status >= 500 108 + ? 'serverError' 109 + : categorizeOauthError(errorBody ?? `HTTP ${res.status}`) 110 + emitOauthTelemetry({ 111 + type: 'oauth:refreshFailed', 112 + payload: { 113 + triggerContext, 114 + errorCategory, 115 + httpStatus: res.status, 116 + message: errorBody?.slice(0, 200), 117 + }, 118 + }) 119 + } 120 + return res 121 + } catch (err) { 122 + emitOauthTelemetry({ 123 + type: 'oauth:refreshFailed', 124 + payload: { 125 + triggerContext, 126 + errorCategory: categorizeOauthError(err), 127 + message: truncateOauthMessage(err), 128 + }, 129 + }) 130 + throw err 131 + } 132 + } 24 133 25 134 function createWebOAuthClient() { 26 135 if (isLoopback()) { ··· 47 156 dpop_bound_access_tokens: true, 48 157 }, 49 158 handleResolver: 'https://blacksky.app', 159 + fetch: oauthInstrumentedFetch, 50 160 }) 51 161 } 52 162 ··· 64 174 dpop_bound_access_tokens: true, 65 175 }, 66 176 handleResolver: 'https://blacksky.app', 177 + fetch: oauthInstrumentedFetch, 67 178 }) 68 179 } 180 + 181 + const BSKY_OAUTH_CLIENT = createWebOAuthClient() 182 + attachOauthTelemetry(BSKY_OAUTH_CLIENT) 69 183 70 184 export function getWebOAuthClient() { 71 185 return BSKY_OAUTH_CLIENT