diff --git a/bun.lock b/bun.lock index 6e6fe62cca..166b6eed87 100644 --- a/bun.lock +++ b/bun.lock @@ -113,7 +113,6 @@ "@formkit/themes": "2.1.0", "@formkit/vue": "2.1.0", "@hono/standard-validator": "^0.3.0", - "@logsnag/node": "1.0.1", "@std/semver": "npm:@jsr/std__semver@1.0.8", "@supabase/supabase-js": "2.110.8", "@vuepic/vue-datepicker": "^14.0.0", @@ -910,8 +909,6 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], - "@logsnag/node": ["@logsnag/node@1.0.1", "", {}, "sha512-JW2S1KN91XyOb0oG2PblboZ1Ys4mkOSMn83GDYjM8CXzcFbkYFMnlFQoEgP0Y5z+1A56hOO+a7uLsvxO5IdUFA=="], - "@mediapipe/tasks-genai": ["@mediapipe/tasks-genai@0.10.27", "", {}, "sha512-cv69CPPAtEDBUs6dGZft2S+sBqde1XvEMST367siSyxrhffdWtm4uQIsfdedAbhJ33BwAjuMnAdxDrO9WrzIAQ=="], "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg=="], diff --git a/cli/src/ai/telemetry.ts b/cli/src/ai/telemetry.ts index 6ba2d80c3c..5c5a442a4b 100644 --- a/cli/src/ai/telemetry.ts +++ b/cli/src/ai/telemetry.ts @@ -52,8 +52,6 @@ export async function trackAiAnalysisChoice(input: TrackAiAnalysisChoiceInput): await sendEvent(input.apikey, { event: 'CLI AI Build Analysis Choice', channel: 'build-lifecycle', - icon: '๐Ÿค–', - notify: false, org_id: input.orgId, tracking_version: 2, tags: { @@ -92,8 +90,6 @@ export async function trackAiAnalysisResult(input: TrackAiAnalysisResultInput): await sendEvent(input.apikey, { event: 'CLI AI Build Analysis Result', channel: 'build-lifecycle', - icon: '๐Ÿค–', - notify: false, org_id: input.orgId, tracking_version: 2, tags, diff --git a/cli/src/analytics/track.ts b/cli/src/analytics/track.ts index eb2aaca82a..5e91c5d417 100644 --- a/cli/src/analytics/track.ts +++ b/cli/src/analytics/track.ts @@ -75,7 +75,6 @@ export function resolveTrackingContext(apikey: string, signal?: AbortSignal): Pr export interface TrackEventInput { channel: string event: string - icon?: string /** Org id for actor-scoped attribution. Omitted => resolved best-effort. */ orgId?: string /** App id (also lets the backend verify org ownership). */ @@ -122,8 +121,6 @@ export function trackEvent(input: TrackEventInput): Promise { await sendEvent(apikey, { channel: input.channel, event: input.event, - icon: input.icon ?? '๐Ÿ“Š', - notify: false, tracking_version: 2, ...(orgId ? { org_id: orgId } : {}), tags, @@ -191,7 +188,6 @@ function emitCommandInvoked(commandPath: string, ctx: CommandContext, apikey?: s apikey, channel: CLI_USAGE_CHANNEL, event: 'CLI Command Invoked', - icon: 'โšก', tags: { command_path: commandPath, flags: ctx.flags.join(','), @@ -226,7 +222,6 @@ export function trackCommandSucceeded(commandPath: string): void { void trackEvent({ channel: CLI_USAGE_CHANNEL, event: 'CLI Command Succeeded', - icon: 'โœ…', tags: { command_path: commandPath, ...(commandStartedAt ? { duration_ms: Date.now() - commandStartedAt } : {}), @@ -238,7 +233,6 @@ export function trackCommandFailed(commandPath: string, opts: { errorCategory: s void trackEvent({ channel: CLI_USAGE_CHANNEL, event: 'CLI Command Failed', - icon: 'โŒ', tags: { command_path: commandPath, error_category: opts.errorCategory, @@ -275,7 +269,6 @@ export function withMcpToolTracking(toolName: string, hand void trackEvent({ channel: MCP_CHANNEL, event: 'MCP Tool Invoked', - icon: '๐Ÿค–', tags: { tool_name: toolName, success, @@ -291,7 +284,6 @@ export function trackMcpServerStarted(hasApikey: boolean): void { void trackEvent({ channel: MCP_CHANNEL, event: 'MCP Server Started', - icon: '๐Ÿค–', tags: { has_apikey: hasApikey, mcp_sdk_version: pack.version, @@ -337,7 +329,7 @@ function recordSupabaseCall(info: SupabaseCallInfo): void { } // Use the key from the Supabase request itself so events fire even when the // key came from --apikey (not env / a saved file); trackEvent still falls back. - void trackEvent({ apikey: info.apikey, channel: CLI_PERF_CHANNEL, event: 'Supabase Call', icon: 'โฑ๏ธ', tags }) + void trackEvent({ apikey: info.apikey, channel: CLI_PERF_CHANNEL, event: 'Supabase Call', tags }) } setSupabaseCallRecorder(recordSupabaseCall) diff --git a/cli/src/app/add.ts b/cli/src/app/add.ts index 6ce2789a37..529b0ef231 100644 --- a/cli/src/app/add.ts +++ b/cli/src/app/add.ts @@ -269,7 +269,6 @@ export async function addAppInternal( org_id: organizationUid, tracking_version: 2, tags: { 'app-id': appId, 'source': appCreateSource }, - notify: false, notifyConsole: true, }).catch(() => {}) diff --git a/cli/src/app/debug.ts b/cli/src/app/debug.ts index f43d3c2879..a267e2d39a 100644 --- a/cli/src/app/debug.ts +++ b/cli/src/app/debug.ts @@ -69,15 +69,13 @@ function describeFetchFailure(error: unknown, endpoint: string) { } -export async function markSnag(channel: string, orgId: string, apikey: string, event: string, appId?: string, icon = 'โœ…', tags?: Record) { +export async function sendCliEvent(channel: string, orgId: string, apikey: string, event: string, appId?: string, tags?: Record) { await sendEvent(apikey, { channel, event, - icon, org_id: orgId, tracking_version: 2, ...((appId || tags) ? { tags: { ...(appId ? { 'app-id': appId } : {}), ...tags } } : {}), - notify: false, }) } @@ -85,7 +83,7 @@ export async function cancelCommand(channel: string, command: boolean | symbol, if (!isCancel(command)) return - await markSnag(channel, orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await sendCliEvent(channel, orgId, apikey, 'canceled') log.warn('Command cancelled') throw new CliUserError('Command cancelled') } @@ -145,13 +143,13 @@ export async function getStats(apikey: string, query: QueryStats, after: string } type Level = 'info' | 'warn' | 'error' -interface LogSpec { summary: (ctx: { data: LogData, baseAppUrl: string, baseUrl: string }) => string, level: Level, snag?: string, stop?: boolean } +interface LogSpec { summary: (ctx: { data: LogData, baseAppUrl: string, baseUrl: string }) => string, level: Level, trackingEvent?: string, stop?: boolean } function summarizeAction(data: LogData): LogSpec | null { const map: Record = { - get: { summary: () => 'Update request by device. Waiting for downloadโ€ฆ', level: 'info', snag: 'done' }, + get: { summary: () => 'Update request by device. Waiting for downloadโ€ฆ', level: 'info', trackingEvent: 'done' }, delete: { summary: () => 'Bundle deleted on device', level: 'info' }, - set: { summary: () => 'Bundle set on device โค๏ธ', level: 'info', snag: 'set', stop: true }, + set: { summary: () => 'Bundle set on device โค๏ธ', level: 'info', trackingEvent: 'set', stop: true }, NoChannelOrOverride: { summary: () => 'No default channel/override; create it in channel settings', level: 'error' }, needPlanUpgrade: { summary: ({ baseUrl }) => `Out of quota. Upgrade plan: ${baseUrl}/settings/organization/plans`, level: 'error' }, missingBundle: { summary: () => 'Requested bundle not found on server', level: 'error' }, @@ -191,7 +189,7 @@ function summarizeAction(data: LogData): LogSpec | null { if (data.action.startsWith('download_')) { const part = data.action.split('_')[1] if (part === 'complete') - return { summary: () => 'Download complete; relaunch app to apply', level: 'info', snag: 'downloaded' } + return { summary: () => 'Download complete; relaunch app to apply', level: 'info', trackingEvent: 'downloaded' } if (part === 'fail') return { summary: () => 'Download failed on device', level: 'error' } return { summary: () => `Downloading ${part}%`, level: 'info' } @@ -203,8 +201,8 @@ async function toTableRow(data: LogData, channel: string, orgId: string, apikey: const spec = summarizeAction(data) if (!spec) return {} - if (spec.snag) - await markSnag(channel, orgId, apikey, spec.snag) + if (spec.trackingEvent) + await sendCliEvent(channel, orgId, apikey, spec.trackingEvent) const time = formatTimeOnly(data.created_at) const key = data.action const versionId = data.version_id ? `(version #${data.version_id})` : '' @@ -263,7 +261,7 @@ function listenForWaitLogContinue(signal: AbortSignal): Promise { export async function waitLog(channel: string, apikey: string, appId: string, orgId: string, options: WaitLogOptions = {}): Promise<{ skipped: boolean }> { const config = await getLocalConfig() const baseAppUrl = `${config.hostWeb}/app/${appId}` - await markSnag(channel, orgId, apikey, 'Use waitlog', appId) + await sendCliEvent(channel, orgId, apikey, 'Use waitlog', appId) const query = buildWaitLogQuery(appId, options.deviceId, options.now, options.lookbackMs) let after: string | null = null // Track displayed log items to avoid duplicates across rounds diff --git a/cli/src/app/delete.ts b/cli/src/app/delete.ts index e46743a463..134bfab511 100644 --- a/cli/src/app/delete.ts +++ b/cli/src/app/delete.ts @@ -150,11 +150,9 @@ export async function deleteAppInternal( await sendEvent(options.apikey, { channel: 'app', event: 'App Deleted', - icon: '๐Ÿ—‘๏ธ', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId }, - notify: false, }).catch(() => {}) } diff --git a/cli/src/app/info.ts b/cli/src/app/info.ts index 93834119a8..326bd6bc49 100644 --- a/cli/src/app/info.ts +++ b/cli/src/app/info.ts @@ -108,7 +108,6 @@ export async function getInfoInternal(options: DoctorInfoOptions, silent = false void trackEvent({ channel: 'cli-usage', event: 'Doctor Ran', - icon: '๐Ÿ‘จโ€โš•๏ธ', tags: computeDoctorAnalyticsTags(installedDependencies, latestDependencies), }) diff --git a/cli/src/app/list.ts b/cli/src/app/list.ts index 927b0f3152..c59ac77a20 100644 --- a/cli/src/app/list.ts +++ b/cli/src/app/list.ts @@ -77,7 +77,7 @@ export async function listAppInternal(options: OptionsBase, silent = false) { supaAnon: options.supaAnon, }) - void trackEvent({ channel: 'app', event: 'Apps Listed', icon: '๐Ÿ“‹', tags: { app_count: allApps.length } }) + void trackEvent({ channel: 'app', event: 'Apps Listed', tags: { app_count: allApps.length } }) if (!allApps.length) { if (!silent) diff --git a/cli/src/app/set.ts b/cli/src/app/set.ts index f008e99f27..a4cf074d2a 100644 --- a/cli/src/app/set.ts +++ b/cli/src/app/set.ts @@ -215,7 +215,6 @@ export async function setAppInternal(appId: string, options: Options, silent = f org_id: organizationUid, tracking_version: 2, tags: { 'app-id': appId }, - notify: false, notifyConsole: true, }).catch(() => {}) diff --git a/cli/src/auth/session.ts b/cli/src/auth/session.ts index 97459eb965..447574549b 100644 --- a/cli/src/auth/session.ts +++ b/cli/src/auth/session.ts @@ -121,9 +121,7 @@ export async function validateAndSaveKey(apikey: string, options: SaveKeyOptions await sendEvent(apikey, { channel: 'user-login', event: 'User CLI login', - icon: 'โœ…', tracking_version: 2, - notify: false, }).catch(() => {}) return { userId } diff --git a/cli/src/build/credentials-command.ts b/cli/src/build/credentials-command.ts index a881856c6b..07aeadad59 100644 --- a/cli/src/build/credentials-command.ts +++ b/cli/src/build/credentials-command.ts @@ -521,7 +521,6 @@ export async function saveCredentialsCommand(options: SaveCredentialsOptions): P await sendEvent(apikey, { channel: 'credentials', event: 'Credentials saved', - icon: '๐Ÿ”', org_id: orgId, tracking_version: 2, tags: { @@ -529,7 +528,6 @@ export async function saveCredentialsCommand(options: SaveCredentialsOptions): P 'platform': platform, 'storage': options.local ? 'local' : 'global', }, - notify: false, }).catch() } } @@ -646,7 +644,7 @@ export async function listCredentialsCommand(options?: { appId?: string, local?: log.info('\n๐Ÿ”’ These credentials are stored locally on your machine only.') log.info(' When building, they are sent to Capgo but NEVER stored there.\n') - void trackEvent({ channel: 'credentials', event: 'Credentials Listed', icon: '๐Ÿ“‹', tags: { credentials_count: appsToShow.length } }) + void trackEvent({ channel: 'credentials', event: 'Credentials Listed', tags: { credentials_count: appsToShow.length } }) } catch (error) { log.error(`Failed to list credentials: ${error instanceof Error ? error.message : String(error)}`) @@ -700,7 +698,7 @@ export async function clearCredentialsCommand(options: { appId?: string, platfor log.info(` Location: ${credentialsPath}\n`) - void trackEvent({ channel: 'credentials', event: 'Credentials Cleared', icon: '๐Ÿงน', tags: {} }) + void trackEvent({ channel: 'credentials', event: 'Credentials Cleared', tags: {} }) } catch (error) { log.error(`Failed to clear credentials: ${error instanceof Error ? error.message : String(error)}`) @@ -955,7 +953,7 @@ export async function updateCredentialsCommand(options: SaveCredentialsOptions): log.success(`\nโœ… ${platform.toUpperCase()} credentials updated for ${appId}!`) log.info(` Location: ${credentialsPath}\n`) - void trackEvent({ channel: 'credentials', event: 'Credentials Updated', icon: 'โœ๏ธ', tags: {} }) + void trackEvent({ channel: 'credentials', event: 'Credentials Updated', tags: {} }) } catch (error) { log.error(`Failed to update credentials: ${error instanceof Error ? error.message : String(error)}`) @@ -1097,7 +1095,7 @@ export async function migrateCredentialsCommand(options: { appId?: string, platf log.info('') - void trackEvent({ channel: 'credentials', event: 'Credentials Migrated', icon: '๐Ÿ”€', tags: {} }) + void trackEvent({ channel: 'credentials', event: 'Credentials Migrated', tags: {} }) } catch (error) { log.error(`Failed to migrate credentials: ${error instanceof Error ? error.message : String(error)}`) diff --git a/cli/src/build/credentials-manage.ts b/cli/src/build/credentials-manage.ts index cc2df48c1c..b6d5625768 100644 --- a/cli/src/build/credentials-manage.ts +++ b/cli/src/build/credentials-manage.ts @@ -513,7 +513,7 @@ export async function manageCredentialsCommand(options: ManageCredentialsOptions if (!handedOffToOnboarding) { pOutro('Done.') - void trackEvent({ channel: 'credentials', event: 'Credentials Managed', icon: '๐Ÿ—‚๏ธ', tags: {} }) + void trackEvent({ channel: 'credentials', event: 'Credentials Managed', tags: {} }) } } catch (error) { diff --git a/cli/src/build/last-output-command.ts b/cli/src/build/last-output-command.ts index a4955b935b..905d5a9861 100644 --- a/cli/src/build/last-output-command.ts +++ b/cli/src/build/last-output-command.ts @@ -54,7 +54,7 @@ export async function lastOutputCommand(options: LastOutputOptions): Promise --apple-key-id --apple-issuer-id `.') - void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Unsupported Platform', icon: '๐Ÿ”‘', apikey: options.apikey, tags: { os_platform: platform } }) + void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Unsupported Platform', apikey: options.apikey, tags: { os_platform: platform } }) await flushAnalytics() exit(1) } @@ -44,7 +44,7 @@ export async function createAppleKeyCommand(options: CreateAppleKeyOptions = {}) if (!resolveHelperBinary()) { log.error('Could not find the App Store Connect key helper binary.') log.info('Update @capgo/cli (and reinstall its optional dependencies) so the signed helper is installed, then try again.') - void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Helper Missing', icon: '๐Ÿ”‘', apikey: options.apikey }) + void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Helper Missing', apikey: options.apikey }) await flushAnalytics() exit(1) } diff --git a/cli/src/build/onboarding/command.ts b/cli/src/build/onboarding/command.ts index 37d367b245..40e19eaeef 100644 --- a/cli/src/build/onboarding/command.ts +++ b/cli/src/build/onboarding/command.ts @@ -198,7 +198,6 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Helper Untrusted', - icon: '๐Ÿ”‘', apikey: options.apikey, tags: { reason: guidedProbe.reason }, }) diff --git a/cli/src/build/onboarding/telemetry.ts b/cli/src/build/onboarding/telemetry.ts index 8eb58cf6bc..cd628d131c 100644 --- a/cli/src/build/onboarding/telemetry.ts +++ b/cli/src/build/onboarding/telemetry.ts @@ -79,8 +79,6 @@ export async function trackBuilderOnboardingStep(input: TrackBuilderOnboardingSt await sendEvent(input.apikey, { event: 'Builder Onboarding Step', channel: 'builder-onboarding', - icon: '๐Ÿงญ', - notify: false, org_id: input.orgId, tracking_version: 2, tags, @@ -109,8 +107,6 @@ export async function trackBuilderOnboardingAction(input: TrackBuilderOnboarding await sendEvent(input.apikey, { event: 'Builder Onboarding Action', channel: 'builder-onboarding', - icon: '๐Ÿงญ', - notify: false, org_id: input.orgId, tracking_version: 2, tags, @@ -173,8 +169,6 @@ export async function trackBuilderOnboardingCancelled(input: TrackBuilderOnboard await sendEvent(input.apikey, { event: 'Builder Onboarding Quit', channel: 'builder-onboarding', - icon: '๐Ÿšช', - notify: false, org_id: input.orgId, tracking_version: 2, tags, diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 47688a35bb..0576979032 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -1079,13 +1079,11 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // blocks or throws into the wizard. const trackVerifyEvent = useCallback(( event: string, - icon: string, tags: Record = {}, ) => { void trackEvent({ channel: 'bundle', event, - icon, apikey: resolvedApiKeyRef.current ?? apikey ?? undefined, appId, orgId: resolvedOrgId ?? undefined, @@ -1179,14 +1177,14 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // fired โ€” Auto Fixed (changed > 0), Create App Opened, Gate Blocked (the // attempt counter advanced while staying parked), Passed (advanced). if (action === 'autofix' && autoFixChanged > 0) - trackVerifyEvent('iOS App Verify Auto Fixed', '๐Ÿ”ง', { attempt: preAttempt, path: 'fix-build-id' }) + trackVerifyEvent('iOS App Verify Auto Fixed', { attempt: preAttempt, path: 'fix-build-id' }) if (action === 'open' || action === 'reopen') - trackVerifyEvent('iOS App Verify Create App Opened', '๐ŸŒ', { attempt: preAttempt }) + trackVerifyEvent('iOS App Verify Create App Opened', { attempt: preAttempt }) const attemptAfter = t?.verifyAttempt ?? preAttempt if (result.next === 'verify-app' && attemptAfter > preAttempt) - trackVerifyEvent('iOS App Verify Gate Blocked', '๐Ÿšง', { attempt: attemptAfter, path: gatePath }) + trackVerifyEvent('iOS App Verify Gate Blocked', { attempt: attemptAfter, path: gatePath }) if (result.next && result.next !== 'verify-app' && result.next !== 'error') { - trackVerifyEvent('iOS App Verify Passed', 'โœ…', { attempts: preAttempt, path: gatePath }) + trackVerifyEvent('iOS App Verify Passed', { attempts: preAttempt, path: gatePath }) setStep(result.next) } } @@ -2407,15 +2405,15 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres const bundleIdCount = t.verifyRegisteredIds?.length ?? 0 if (!verifyShownRef.current) { verifyShownRef.current = true - trackVerifyEvent('iOS App Verify Shown', '๐Ÿ”', { + trackVerifyEvent('iOS App Verify Shown', { app_count: appCount, bundle_id_count: bundleIdCount, debug_release_differ: t.verifyDebugReleaseDiffer ?? false, }) } - trackVerifyEvent('iOS App Verify Result', '๐Ÿ”Ž', { result: t.verifyResult, app_count: appCount, bundle_id_count: bundleIdCount }) + trackVerifyEvent('iOS App Verify Result', { result: t.verifyResult, app_count: appCount, bundle_id_count: bundleIdCount }) if (t.verifyResult === 'exact-match') - trackVerifyEvent('iOS App Verify Passed', 'โœ…', { attempts: 0, path: 'exact-match' }) + trackVerifyEvent('iOS App Verify Passed', { attempts: 0, path: 'exact-match' }) } } @@ -3621,7 +3619,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // exactly as before (the engine's error-sink 'cancel' is for headless // drivers, which have no exitOnboarding). const cancelGate = (path: GatePath) => { - trackVerifyEvent('iOS App Verify Cancelled', '๐Ÿšซ', { attempt: verifyAttempt, path }) + trackVerifyEvent('iOS App Verify Cancelled', { attempt: verifyAttempt, path }) addLog('Exiting onboarding.', 'yellow') exitOnboarding() } @@ -3784,12 +3782,12 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres ]} onChange={(value) => { if (value === '__create_new__') { - trackVerifyEvent('iOS App Verify Picked', '๐Ÿ‘†', { matches_build_id: false, chose_create_new: true }) + trackVerifyEvent('iOS App Verify Picked', { matches_build_id: false, chose_create_new: true }) void runVerifyGateAction('create-new') return } const chosen = verifyApps.find(a => a.bundleId === value) ?? null - trackVerifyEvent('iOS App Verify Picked', '๐Ÿ‘†', { + trackVerifyEvent('iOS App Verify Picked', { matches_build_id: value === releaseId, chose_create_new: false, }) diff --git a/cli/src/build/prescan/command.ts b/cli/src/build/prescan/command.ts index 7a8ecd401d..6bb937a503 100644 --- a/cli/src/build/prescan/command.ts +++ b/cli/src/build/prescan/command.ts @@ -111,7 +111,6 @@ export async function prescanCommand(appId: string | undefined, options: Prescan await sendEvent(apikeyUsedForScan, { channel: 'build', event: 'Prescan run', - icon: '๐Ÿ›ก๏ธ', tags: { 'source': 'standalone', 'result': enforced.error > 0 ? (options.ignoreFatal ? 'bypassed' : 'blocked') : enforced.warning > 0 ? (options.failOnWarnings ? 'blocked' : 'warned') : informationOnly > 0 ? 'information-only' : 'clean', @@ -122,7 +121,6 @@ export async function prescanCommand(appId: string | undefined, options: Prescan 'finding-ids': report.findings.filter(f => f.severity !== 'info').map(f => f.id).join(',').slice(0, 200), 'information-only-findings': String(informationOnly), }, - notify: false, }, options.verbose).catch(() => {}) } exit(exitCodeFor(report.counts, options, report.findings)) diff --git a/cli/src/build/request.ts b/cli/src/build/request.ts index 39e184a57b..9123e2107b 100644 --- a/cli/src/build/request.ts +++ b/cli/src/build/request.ts @@ -1910,7 +1910,6 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO await sendEvent(options.apikey, { channel: 'native-builder', event: 'Prescan run', - icon: '๐Ÿ›ก๏ธ', org_id: orgId, tracking_version: 2, tags: { @@ -1924,7 +1923,6 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO 'bypassed': String(prescanResult === 'bypassed'), 'information-only-findings': String(gateInformationOnlyFindings), }, - notify: false, }).catch(() => {}) } if (gateDecision === 'block') { @@ -2032,7 +2030,6 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO await sendEvent(options.apikey, { channel: 'native-builder', event: 'Build requested', - icon: '๐Ÿ—๏ธ', org_id: orgId, tracking_version: 2, tags: { @@ -2042,7 +2039,6 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO // with the rest of the journey's events. ...(options.builderJourneyId ? { journey_id: options.builderJourneyId } : {}), }, - notify: false, }).catch() // Create temporary directory for zip @@ -2795,7 +2791,6 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO await sendEvent(options.apikey, { channel: 'native-builder', event: finalStatus === 'succeeded' ? 'Build succeeded' : 'Build failed', - icon: finalStatus === 'succeeded' ? 'โœ…' : 'โŒ', org_id: orgId, tracking_version: 2, tags: { @@ -2807,7 +2802,6 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO // outcome with the rest of the journey's events. ...(options.builderJourneyId ? { journey_id: options.builderJourneyId } : {}), }, - notify: false, }).catch() return { diff --git a/cli/src/build/telemetry.ts b/cli/src/build/telemetry.ts index 02dc4e25aa..d0f911c984 100644 --- a/cli/src/build/telemetry.ts +++ b/cli/src/build/telemetry.ts @@ -52,12 +52,6 @@ const EVENT_NAME_BY_PHASE: Record = { failed: 'Builder Upload Failed', } -const ICON_BY_PHASE: Record = { - started: 'โฌ†๏ธ', - succeeded: '๐Ÿ“ฆ', - failed: '๐Ÿšซ', -} - export async function trackBuilderUpload(input: TrackBuilderUploadInput): Promise { const tags: Record = { app_id: input.appId, @@ -77,8 +71,6 @@ export async function trackBuilderUpload(input: TrackBuilderUploadInput): Promis await sendEvent(input.apikey, { event: EVENT_NAME_BY_PHASE[input.phase], channel: 'build-lifecycle', - icon: ICON_BY_PHASE[input.phase], - notify: false, org_id: input.orgId, tracking_version: 2, tags, diff --git a/cli/src/bundle/builder-cta.ts b/cli/src/bundle/builder-cta.ts index c78035341e..5bf4dff59b 100644 --- a/cli/src/bundle/builder-cta.ts +++ b/cli/src/bundle/builder-cta.ts @@ -126,7 +126,6 @@ async function runBuilderCta(params: MaybePromptBuilderCtaParams): Promise !isCompatible(entry)).length, diff --git a/cli/src/bundle/decrypt.ts b/cli/src/bundle/decrypt.ts index f4c0ff4abe..e611faed12 100644 --- a/cli/src/bundle/decrypt.ts +++ b/cli/src/bundle/decrypt.ts @@ -117,7 +117,7 @@ export async function decryptZipInternal( log.info('Checksum matches') } - void trackEvent({ channel: 'bundle', event: 'Bundle Decrypted', icon: '๐Ÿ”“', tags: {} }) + void trackEvent({ channel: 'bundle', event: 'Bundle Decrypted', tags: {} }) if (!silent) outro('โœ… done') diff --git a/cli/src/bundle/delete.ts b/cli/src/bundle/delete.ts index 3b0ccecd72..7b04ed6748 100644 --- a/cli/src/bundle/delete.ts +++ b/cli/src/bundle/delete.ts @@ -52,11 +52,9 @@ export async function deleteBundleInternal(bundleId: string, appId: string, opti await sendEvent(options.apikey, { channel: 'app', event: 'Bundle Deleted', - icon: '๐Ÿ—‘๏ธ', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, 'bundle': bundleId }, - notify: false, notifyConsole: true, }).catch(() => {}) diff --git a/cli/src/bundle/encrypt.ts b/cli/src/bundle/encrypt.ts index c088267273..6a0774d655 100644 --- a/cli/src/bundle/encrypt.ts +++ b/cli/src/bundle/encrypt.ts @@ -125,7 +125,7 @@ export async function encryptZipInternal( writeFileSync(filenameEncrypted, encryptedData) - void trackEvent({ channel: 'bundle', event: 'Bundle Encrypted', icon: '๐Ÿ”’', tags: {} }) + void trackEvent({ channel: 'bundle', event: 'Bundle Encrypted', tags: {} }) if (!silent) { if (json) { diff --git a/cli/src/bundle/list.ts b/cli/src/bundle/list.ts index 09a3850c99..48d2419810 100644 --- a/cli/src/bundle/list.ts +++ b/cli/src/bundle/list.ts @@ -37,7 +37,7 @@ export async function listBundle(appId: string, options: OptionsBase, silent = f const allVersions = await getActiveAppVersions(options.apikey!, appId, { silent, apikey: options.apikey!, supaHost: options.supaHost, supaAnon: options.supaAnon }) - void trackEvent({ channel: 'bundle', event: 'Bundles Listed', icon: '๐Ÿ“‹', tags: { bundle_count: allVersions?.length ?? 0 } }) + void trackEvent({ channel: 'bundle', event: 'Bundles Listed', tags: { bundle_count: allVersions?.length ?? 0 } }) if (!silent) { log.info(`Active versions in Capgo: ${allVersions?.length ?? 0}`) diff --git a/cli/src/bundle/partial.ts b/cli/src/bundle/partial.ts index 9edb4643b7..a6ac1d037a 100644 --- a/cli/src/bundle/partial.ts +++ b/cli/src/bundle/partial.ts @@ -151,13 +151,11 @@ export async function prepareBundlePartialFiles( await sendEvent(apikey, { channel: 'partial-update', event: 'Generate manifest', - icon: '๐Ÿ“‚', org_id: orgId, tracking_version: 2, tags: { 'app-id': appid, }, - notify: false, }) return manifest @@ -386,25 +384,21 @@ headers: buildCliRequestHeaders({ Authorization: apikey }), await sendEvent(apikey, { channel: 'app', event: `App Partial TUS done${brFilesCount > 0 ? ' with .br extension' : ''}`, - icon: 'โซ', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, }, - notify: false, }) await sendEvent(apikey, { channel: 'performance', event: 'Partial upload performance', - icon: '๐Ÿš„', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, 'time': uploadTime, }, - notify: false, }) return results } diff --git a/cli/src/bundle/releaseType.ts b/cli/src/bundle/releaseType.ts index 27e4a5c231..4ac410065b 100644 --- a/cli/src/bundle/releaseType.ts +++ b/cli/src/bundle/releaseType.ts @@ -41,7 +41,7 @@ export async function printReleaseType(appId: string, options: BundleReleaseType `Request build: npx @capgo/cli@latest build request ${resolvedAppId} --platform --path .`, ] stdout.write(`${lines.join('\n')}\n`) - void trackEvent({ channel: 'bundle', event: 'Release Type Printed', icon: '๐Ÿงญ', tags: { release_type: releaseType } }) + void trackEvent({ channel: 'bundle', event: 'Release Type Printed', tags: { release_type: releaseType } }) } catch (error) { log.error(`Error checking release type ${formatError(error)}`) diff --git a/cli/src/bundle/unlink.ts b/cli/src/bundle/unlink.ts index 30a7eccf65..b124c439c3 100644 --- a/cli/src/bundle/unlink.ts +++ b/cli/src/bundle/unlink.ts @@ -108,13 +108,11 @@ export async function unlinkDeviceInternal( await sendEvent(enrichedOptions.apikey, { channel: 'bundle', event: 'Unlink bundle', - icon: 'โœ…', org_id: orgId, tracking_version: 2, tags: { 'app-id': resolvedAppId, }, - notify: false, }).catch(() => {}) if (!silent) diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 722ef2cb3c..9747fdad6e 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -265,7 +265,6 @@ async function verifyCompatibility(supabase: SupabaseType, pm: pmType, options: void trackEvent({ channel: 'bundle', event: 'Bundle Upload Compatibility Checked', - icon: '๐Ÿงช', apikey: options.apikey, appId: appid, orgId, @@ -501,13 +500,11 @@ async function prepareBundleFile(path: string, options: OptionsUpload, apikey: s await sendEvent(apikey, { channel: 'app', event: 'App encryption v2', - icon: '๐Ÿ”‘', org_id: orgId, tracking_version: 2, tags: { 'app-id': appid, }, - notify: false, }, options.verbose) if (!keyDataV2) { const keyFile = readFileSync(privateKey) @@ -565,14 +562,12 @@ async function prepareBundleFile(path: string, options: OptionsUpload, apikey: s await sendEvent(apikey, { channel: 'app-error', event: 'App Too Large', - icon: '๐Ÿš›', org_id: orgId, tracking_version: 2, tags: { 'app-id': appid, 'size_mb': mbSize, }, - notify: false, }, options.verbose) if (options.verbose) @@ -776,14 +771,12 @@ async function uploadBundleToCapgoCloud(apikey: string, supabase: SupabaseType, await sendEvent(apikey, { channel: 'performance', event: isTus ? 'TUS upload zip performance' : 'Upload zip performance', - icon: '๐Ÿš„', org_id: orgId, tracking_version: 2, tags: { 'app-id': appid, 'time': uploadTime, }, - notify: false, }, options.verbose) if (options.verbose) @@ -1553,7 +1546,6 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio void trackEvent({ channel: 'bundle', event: 'Bundle Upload Blocked', - icon: 'โ›”', apikey: options.apikey, appId: appid, orgId, @@ -1640,13 +1632,11 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio await sendEvent(apikey, { channel: 'app', event: 'App external', - icon: '๐Ÿ“ค', org_id: orgId, tracking_version: 2, tags: { 'app-id': appid, }, - notify: false, }, options.verbose) if (options.verbose) { @@ -1992,27 +1982,23 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio await sendEvent(apikey, { channel: 'app', event: 'App Uploaded', - icon: 'โซ', org_id: orgId, tracking_version: 2, tags: { 'app-id': appid, 'bundle': bundle, }, - notify: false, }, options.verbose) await sendEvent(apikey, { channel: 'app', event: 'Bundle Uploaded', - icon: 'โซ', org_id: orgId, tracking_version: 2, tags: { 'app-id': appid, 'bundle': bundle, }, - notify: false, notifyConsole: true, }).catch(() => {}) @@ -2026,7 +2012,6 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio void trackEvent({ channel: 'bundle', event: 'Bundle Incompatible', - icon: '๐Ÿšซ', apikey, appId: appid, orgId, diff --git a/cli/src/bundle/zip.ts b/cli/src/bundle/zip.ts index 84ef4e904c..64aa886182 100644 --- a/cli/src/bundle/zip.ts +++ b/cli/src/bundle/zip.ts @@ -170,7 +170,7 @@ export async function zipBundleInternal(appId: string, options: BundleZipOptions if (saveSpinner) saveSpinner.stop(`Saved to ${filename}`) - void trackEvent({ channel: 'bundle', event: 'Bundle Zipped', icon: '๐Ÿ—œ๏ธ', tags: { zip_size_bytes: zipped.byteLength } }) + void trackEvent({ channel: 'bundle', event: 'Bundle Zipped', tags: { zip_size_bytes: zipped.byteLength } }) if (shouldShowPrompts) outro('Done โœ…') diff --git a/cli/src/channel/add.ts b/cli/src/channel/add.ts index 924b3207ca..8b5f353734 100644 --- a/cli/src/channel/add.ts +++ b/cli/src/channel/add.ts @@ -67,14 +67,12 @@ export async function addChannelInternal(channelId: string, appId: string, optio await sendEvent(options.apikey, { channel: 'channel', event: 'Create channel', - icon: 'โœ…', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, 'channel': channelId, }, - notify: false, }).catch(() => {}) if (!silent) { diff --git a/cli/src/channel/currentBundle.ts b/cli/src/channel/currentBundle.ts index 43183a973a..36a17ab0b3 100644 --- a/cli/src/channel/currentBundle.ts +++ b/cli/src/channel/currentBundle.ts @@ -75,7 +75,7 @@ export async function currentBundleInternal(channel: string, appId: string, opti throw new CliUserError('Insufficient permissions for channel. Required RBAC permission for this action: channel.read.', { appId, channel }) } - void trackEvent({ channel: 'channel', event: 'Channel Current Bundle Viewed', icon: '๐Ÿ“ฆ', tags: { has_bundle: Boolean(version) } }) + void trackEvent({ channel: 'channel', event: 'Channel Current Bundle Viewed', tags: { has_bundle: Boolean(version) } }) if (!version) { if (!silent) diff --git a/cli/src/channel/delete.ts b/cli/src/channel/delete.ts index 714f7e3aca..7228c92177 100644 --- a/cli/src/channel/delete.ts +++ b/cli/src/channel/delete.ts @@ -128,14 +128,12 @@ export async function deleteChannelInternal(channelId: string, appId: string, op await sendEvent(options.apikey, { channel: 'channel', event: 'Delete channel', - icon: 'โœ…', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, 'channel': channelId, }, - notify: false, }).catch(() => {}) if (!silent) { diff --git a/cli/src/channel/list.ts b/cli/src/channel/list.ts index 531c074478..ca9d9b3a81 100644 --- a/cli/src/channel/list.ts +++ b/cli/src/channel/list.ts @@ -42,13 +42,11 @@ export async function listChannelsInternal(appId: string, options: OptionsBase, await sendEvent(options.apikey, { channel: 'channel', event: 'List channel', - icon: 'โœ…', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, }, - notify: false, }).catch(() => {}) if (!silent) diff --git a/cli/src/channel/set.ts b/cli/src/channel/set.ts index b37210ac93..0aad958c26 100644 --- a/cli/src/channel/set.ts +++ b/cli/src/channel/set.ts @@ -754,13 +754,11 @@ export async function setChannelInternal(channel: string, appId: string, options await sendEvent(options.apikey, { channel: 'channel', event: 'Set channel', - icon: 'โœ…', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, }, - notify: false, }).catch(() => {}) if (!silent) diff --git a/cli/src/github-command.ts b/cli/src/github-command.ts index d0aebd1732..74daaf75cf 100644 --- a/cli/src/github-command.ts +++ b/cli/src/github-command.ts @@ -11,7 +11,7 @@ export function starRepositoryCommand(repository?: string) { } else { log.success(`๐Ÿ™ Thanks for starring ${fullRepo} ๐ŸŽ‰`) - void trackEvent({ channel: 'cli-usage', event: 'Repo Starred', icon: 'โญ', tags: { repo_count: 1 } }) + void trackEvent({ channel: 'cli-usage', event: 'Repo Starred', tags: { repo_count: 1 } }) } } @@ -124,7 +124,7 @@ export async function starAllRepositoriesCommand(repositories: string[], options const alreadyStarredCount = result.filter(entry => entry.status === 'already_starred').length const failedCount = result.filter(entry => entry.status === 'failed').length if (starredCount > 0) - void trackEvent({ channel: 'cli-usage', event: 'Repo Starred', icon: 'โญ', tags: { repo_count: starredCount } }) + void trackEvent({ channel: 'cli-usage', event: 'Repo Starred', tags: { repo_count: starredCount } }) const completionMessage = !hasResult ? 'No repositories were processed.' : `Completed ${result.length} repository(s): ${starredCount} starred, ${alreadyStarredCount} already starred, ${failedCount} failed.` diff --git a/cli/src/init/browser-login.ts b/cli/src/init/browser-login.ts index bfba1528a1..9ddbed2679 100644 --- a/cli/src/init/browser-login.ts +++ b/cli/src/init/browser-login.ts @@ -17,7 +17,6 @@ interface BrowserLoginEvent { org_id: string description: string notifyConsole: true - notify: false } interface BrowserLoginDependencies { @@ -94,7 +93,6 @@ export async function loginInitInBrowser( org_id: orgId, description: `cli-login:${session}`, notifyConsole: true, - notify: false, }))) } catch { diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 8f7a483f64..cceaa61d9f 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -16,7 +16,7 @@ import { checkAppIdsExist, completePendingOnboardingApp, findAppInOrganization, import { checkVersionStatus } from '../api/update' import { flushDeferredCommandInvocation } from '../analytics/track' import { addAppInternal } from '../app/add' -import { markSnag, waitLog } from '../app/debug' +import { sendCliEvent, waitLog } from '../app/debug' import { deleteAppInternal } from '../app/delete' import { resolveInitCommandInput } from '../auth/command-input' import { getInfoInternal } from '../app/info' @@ -731,7 +731,7 @@ async function runInitDoctorDiagnostics(): Promise { } async function exitCanceledInitOnboarding(orgId: string, apikey: string, message = 'You can resume the onboarding anytime by running the same command again'): Promise { - await markInitSnag(orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await recordInitEvent(orgId, apikey, 'canceled') pOutro(`Bye ๐Ÿ‘‹\n๐Ÿ’ก ${message}`) return await exitAfterFinishingReplay('cancelled', 1) } @@ -1640,7 +1640,7 @@ function cleanupStepsDone() { async function cancelCommand(command: boolean | string | symbol, orgId: string, apikey: string) { if (pIsCancel(command)) { - await markInitSnag(orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await recordInitEvent(orgId, apikey, 'canceled') pOutro(`Bye ๐Ÿ‘‹\n๐Ÿ’ก You can resume the onboarding anytime by running the same command again`) return await exitAfterFinishingReplay('cancelled', 0) } @@ -1675,7 +1675,7 @@ async function selectRecoveryOption( }) if (pIsCancel(choice) || choice === '__cancel__') { - await markInitSnag(orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await recordInitEvent(orgId, apikey, 'canceled') pOutro(`Bye ๐Ÿ‘‹\n๐Ÿ’ก You can resume the onboarding anytime by running the same command again`) return await exitAfterFinishingReplay('cancelled', 1) } @@ -1818,16 +1818,16 @@ async function warnIfNotInCapacitorRoot() { } } -async function markInitSnag(orgId: string, apikey: string, event: string, appId?: string, icon = 'โœ…') { +async function recordInitEvent(orgId: string, apikey: string, event: string, appId?: string) { activeInitTelemetry?.setAuth(orgId, apikey) if (activeInitTelemetry) - return activeInitTelemetry.recordMilestone(event, undefined, icon, appId ?? null) + return activeInitTelemetry.recordMilestone(event, undefined, appId ?? null) const replaySessionId = getActiveCliReplaySessionId() - return markSnag('onboarding-v2', orgId, apikey, event, appId, icon, replaySessionId ? { $session_id: replaySessionId } : undefined) + return sendCliEvent('onboarding-v2', orgId, apikey, event, appId, replaySessionId ? { $session_id: replaySessionId } : undefined) } async function markStep(orgId: string, apikey: string, step: string, appId: string) { - return markInitSnag(orgId, apikey, `onboarding-step-${step}`, appId) + return recordInitEvent(orgId, apikey, `onboarding-step-${step}`, appId) } /** * Save the app ID to the CapacitorUpdater plugin config. @@ -2463,7 +2463,7 @@ async function askForReplacementAppId( await cancelCommand(choice, organization.gid, apikey) if (choice === 'cancel') { - await markInitSnag(organization.gid, apikey, 'canceled-appid-conflict', undefined, '๐Ÿคท') + await recordInitEvent(organization.gid, apikey, 'canceled-appid-conflict') pOutro(`Bye ๐Ÿ‘‹\n๐Ÿ’ก You can resume the onboarding anytime by running the same command again`) return await exitAfterFinishingReplay('cancelled', 0) } @@ -3370,7 +3370,7 @@ async function addEncryptionStep(orgId: string, apikey: string, appId: string) { // log buffer, which `renderInitOnboardingFrame` wipes when step 6 // renders โ€” producing a visible "flash" of the success line. s.stop() - await markInitSnag(orgId, apikey, 'Use encryption v2', appId) + await recordInitEvent(orgId, apikey, 'Use encryption v2', appId) // Run `cap sync` now, inside step 5, so the public key we just wrote // to `capacitor.config.*` actually lands in the native projects @@ -4661,7 +4661,7 @@ async function addCodeChangeStep(orgId: string, apikey: string, appId: string, p ], }) if (pIsCancel(modificationType)) { - await markInitSnag(orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await recordInitEvent(orgId, apikey, 'canceled') pOutro(`Bye ๐Ÿ‘‹\n๐Ÿ’ก You can resume the onboarding anytime by running the same command again`) return await exitAfterFinishingReplay('cancelled', 0) } @@ -4747,7 +4747,7 @@ async function addCodeChangeStep(orgId: string, apikey: string, appId: string, p ], }) if (pIsCancel(versionChoice)) { - await markInitSnag(orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await recordInitEvent(orgId, apikey, 'canceled') pOutro(`Bye ๐Ÿ‘‹\n๐Ÿ’ก You can resume the onboarding anytime by running the same command again`) return await exitAfterFinishingReplay('cancelled', 0) } @@ -4768,7 +4768,7 @@ async function addCodeChangeStep(orgId: string, apikey: string, appId: string, p }, }) if (pIsCancel(userVersion)) { - await markInitSnag(orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await recordInitEvent(orgId, apikey, 'canceled') pOutro(`Bye ๐Ÿ‘‹\n๐Ÿ’ก You can resume the onboarding anytime by running the same command again`) return await exitAfterFinishingReplay('cancelled', 0) } diff --git a/cli/src/init/telemetry.ts b/cli/src/init/telemetry.ts index e638b1efb6..a7c1fd17e0 100644 --- a/cli/src/init/telemetry.ts +++ b/cli/src/init/telemetry.ts @@ -10,7 +10,7 @@ export interface InitProgressTelemetry { } export interface InitTelemetryOptions { - capture?: (event: string, properties: TelemetryProperties, icon: string, appId?: string) => Promise | void + capture?: (event: string, properties: TelemetryProperties, appId?: string) => Promise | void enabled?: boolean replaySessionId?: () => string | undefined } @@ -64,7 +64,7 @@ export function createInitTelemetry(options: InitTelemetryOptions = {}) { } } - async function emit(event: string, extra?: TelemetryProperties, once = false, icon = 'โœ…', eventAppId: string | null = appId ?? null) { + async function emit(event: string, extra?: TelemetryProperties, once = false, eventAppId: string | null = appId ?? null) { if (!enabled || (once && recorded.has(event))) return if (once) @@ -72,10 +72,10 @@ export function createInitTelemetry(options: InitTelemetryOptions = {}) { try { const eventProperties = properties(extra) if (options.capture) - await options.capture(event, eventProperties, icon, eventAppId ?? undefined) + await options.capture(event, eventProperties, eventAppId ?? undefined) else if (auth) { const { apikey, orgId } = auth - await import('../app/debug').then(({ markSnag }) => markSnag('onboarding-v2', orgId, apikey, event, eventAppId ?? undefined, icon, eventProperties)) + await import('../app/debug').then(({ sendCliEvent }) => sendCliEvent('onboarding-v2', orgId, apikey, event, eventAppId ?? undefined, eventProperties)) } } catch { @@ -109,7 +109,7 @@ export function createInitTelemetry(options: InitTelemetryOptions = {}) { candidate = saved ? { ...saved, savedStep, totalSteps } : { journey_id: `ij_${randomUUID()}`, savedStep, totalSteps } return { journey_id: candidate.journey_id, ...(candidate.last_run_id ? { last_run_id: candidate.last_run_id } : {}) } }, - recordMilestone: (event: string, extra?: TelemetryProperties, icon = 'โœ…', eventAppId?: string | null) => emit(event, extra, false, icon, eventAppId), + recordMilestone: (event: string, extra?: TelemetryProperties, eventAppId?: string | null) => emit(event, extra, false, eventAppId), recordResumeDecision: async (nextChoice: 'continue' | 'restart') => { if (!candidate || choice) return @@ -124,7 +124,7 @@ export function createInitTelemetry(options: InitTelemetryOptions = {}) { }, true) }, recordResumePromptViewed: () => candidate ? emit('onboarding-resume-prompt-viewed', { ...resumeProperties(), ...(candidate.totalSteps === undefined ? {} : { total_steps: candidate.totalSteps }) }, true) : Promise.resolve(), - recordRunEnded: (outcome: 'completed' | 'cancelled' | 'failed', exitCode: number) => emit('onboarding-run-ended', { outcome, exit_code: exitCode }, true, outcome === 'completed' ? 'โœ…' : outcome === 'cancelled' ? '๐Ÿคท' : 'โŒ'), + recordRunEnded: (outcome: 'completed' | 'cancelled' | 'failed', exitCode: number) => emit('onboarding-run-ended', { outcome, exit_code: exitCode }, true), recordRunStarted: () => emit('onboarding-run-started', { resume_available: Boolean(candidate), ...resumeProperties(), diff --git a/cli/src/key.ts b/cli/src/key.ts index 6e353c7d20..ea7f388148 100644 --- a/cli/src/key.ts +++ b/cli/src/key.ts @@ -70,7 +70,7 @@ export async function saveKeyInternal(options: SaveOptions, silent = false) { updaterConfig.publicKey = publicKey await writeConfigUpdater(extConfig) - void trackEvent({ channel: 'key', event: 'Key Saved', icon: '๐Ÿ’พ', tags: {} }) + void trackEvent({ channel: 'key', event: 'Key Saved', tags: {} }) } if (!silent) { @@ -120,7 +120,7 @@ export async function deleteOldPrivateKeyInternal(options: Options, silent = fal log.success(`Old private key deleted from ${extConfig.path} file`) outro('Done โœ…') } - void trackEvent({ channel: 'key', event: 'Old Key Deleted', icon: '๐Ÿงน', tags: {} }) + void trackEvent({ channel: 'key', event: 'Old Key Deleted', tags: {} }) return true } @@ -183,7 +183,7 @@ export async function createKeyInternal(options: Options, silent = false, existi await writeConfigUpdater(extConfig) } - void trackEvent({ channel: 'key', event: 'Encryption Keys Generated', icon: '๐Ÿ”‘', tags: {} }) + void trackEvent({ channel: 'key', event: 'Encryption Keys Generated', tags: {} }) if (!silent) { log.success('Your RSA key has been generated') diff --git a/cli/src/organization/add.ts b/cli/src/organization/add.ts index f40504f3f8..007b660c84 100644 --- a/cli/src/organization/add.ts +++ b/cli/src/organization/add.ts @@ -90,13 +90,11 @@ export async function addOrganizationInternal(options: OrganizationAddOptions, s await sendEvent(enrichedOptions.apikey, { channel: 'organization', event: 'Organization Created', - icon: '๐Ÿข', org_id: orgData.id, tracking_version: 2, tags: { 'org-name': name, }, - notify: false, }).catch(() => {}) if (!silent) { diff --git a/cli/src/organization/delete.ts b/cli/src/organization/delete.ts index dbc4a208a6..20b39d91ee 100644 --- a/cli/src/organization/delete.ts +++ b/cli/src/organization/delete.ts @@ -98,13 +98,11 @@ export async function deleteOrganizationInternal( await sendEvent(enrichedOptions.apikey, { channel: 'organization', event: 'Organization Deleted', - icon: '๐Ÿ—‘๏ธ', org_id: orgId, tracking_version: 2, tags: { 'org-name': orgData.name, }, - notify: false, }).catch(() => {}) if (!silent) { diff --git a/cli/src/organization/list.ts b/cli/src/organization/list.ts index 08edfcb07a..49dfb7b101 100644 --- a/cli/src/organization/list.ts +++ b/cli/src/organization/list.ts @@ -90,7 +90,7 @@ export async function listOrganizationsInternal(options: OptionsBase, silent = f const organizations = allOrganizations || [] - void trackEvent({ channel: 'organization', event: 'Orgs Listed', icon: '๐Ÿ“‹', tags: { org_count: organizations.length } }) + void trackEvent({ channel: 'organization', event: 'Orgs Listed', tags: { org_count: organizations.length } }) if (!silent) { log.info(`Organizations found: ${organizations.length}`) diff --git a/cli/src/organization/members.ts b/cli/src/organization/members.ts index 99dbc1e557..5b745c7099 100644 --- a/cli/src/organization/members.ts +++ b/cli/src/organization/members.ts @@ -197,7 +197,7 @@ export async function listMembersInternal(orgId: string, options: OptionsBase, s } }) - void trackEvent({ channel: 'organization', event: 'Org Members Listed', icon: '๐Ÿ‘ฅ', tags: { member_count: memberInfoList.length, with_2fa_count: memberInfoList.filter(m => m.has_2fa).length } }) + void trackEvent({ channel: 'organization', event: 'Org Members Listed', tags: { member_count: memberInfoList.length, with_2fa_count: memberInfoList.filter(m => m.has_2fa).length } }) if (!silent) { log.info(`Members found: ${memberInfoList.length}`) diff --git a/cli/src/organization/set.ts b/cli/src/organization/set.ts index 198492d3d1..e4052a35b9 100644 --- a/cli/src/organization/set.ts +++ b/cli/src/organization/set.ts @@ -242,14 +242,12 @@ export async function setOrganizationInternal( await sendEvent(enrichedOptions.apikey, { channel: 'organization', event: enforce2fa ? 'Organization 2FA Enabled' : 'Organization 2FA Disabled', - icon: '๐Ÿ”', org_id: orgId, tracking_version: 2, tags: { 'org-name': orgData.name, 'enforce-2fa': enforce2fa.toString(), }, - notify: false, }).catch(() => {}) if (!silent) { @@ -340,13 +338,11 @@ export async function setOrganizationInternal( await sendEvent(enrichedOptions.apikey, { channel: 'organization', event: passwordPolicy ? 'Password Policy Enabled' : 'Password Policy Disabled', - icon: '๐Ÿ”‘', org_id: orgId, tracking_version: 2, tags: { 'org-name': orgData.name, }, - notify: false, }).catch(() => {}) if (!silent) { @@ -415,13 +411,11 @@ export async function setOrganizationInternal( await sendEvent(enrichedOptions.apikey, { channel: 'organization', event: 'API Key Settings Updated', - icon: '๐Ÿ”', org_id: orgId, tracking_version: 2, tags: { 'org-name': orgData.name, }, - notify: false, }).catch(() => {}) if (!silent) { @@ -484,13 +478,11 @@ export async function setOrganizationInternal( await sendEvent(enrichedOptions.apikey, { channel: 'organization', event: 'Organization Updated', - icon: 'โœ๏ธ', org_id: orgId, tracking_version: 2, tags: { 'org-name': name, }, - notify: false, }).catch(() => {}) if (!silent) { diff --git a/cli/src/probe.ts b/cli/src/probe.ts index 82463343cd..ec025b9f62 100644 --- a/cli/src/probe.ts +++ b/cli/src/probe.ts @@ -67,7 +67,7 @@ export async function probeInternal(options: ProbeOptions): Promise, o type TagKey = Lowercase /** Tag Type */ type Tags = Record -type Parser = 'markdown' | 'text' /** - * Options for publishing LogSnag events + * Options for publishing analytics events */ interface TrackOptions { /** @@ -235,25 +234,11 @@ interface TrackOptions { * Tracking payload contract version. */ tracking_version?: number - /** - * Event icon (emoji) - * must be a single emoji - * example: "๐ŸŽ‰" - */ - icon?: string /** * Event tags * example: { username: "mattie" } */ tags?: Tags - /** - * Send push notification - */ - notify?: boolean - /** - * Parser for description - */ - parser?: Parser /** * Event timestamp */ @@ -1716,13 +1701,11 @@ export async function uploadTUS(apikey: string, data: Buffer, orgId: string, app sendEvent(apikey, { channel: 'app', event: 'App TUS upload', - icon: 'โซ', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, }, - notify: false, }) const upload = new tus.Upload(data as any, { endpoint: `${localConfig.hostFilesApi}/files/upload/attachments/`, @@ -1781,13 +1764,11 @@ export async function uploadTUS(apikey: string, data: Buffer, orgId: string, app await sendEvent(apikey, { channel: 'app', event: 'App TUS done', - icon: 'โซ', org_id: orgId, tracking_version: 2, tags: { 'app-id': appId, }, - notify: false, }).catch() resolve(true) }, @@ -1889,7 +1870,12 @@ export async function updateOrCreateChannel(supabase: SupabaseClient, .single() } -export async function sendEvent(capgkey: string, payload: TrackOptions & { notifyConsole?: boolean, nonPersonTags?: Record }, verbose?: boolean, signal?: AbortSignal): Promise { +type SendEventPayload = TrackOptions & { nonPersonTags?: Record } & ( + | { notifyConsole: true, icon?: string } + | { notifyConsole?: false, icon?: never } +) + +export async function sendEvent(capgkey: string, payload: SendEventPayload, verbose?: boolean, signal?: AbortSignal): Promise { const telemetryDisabled = isTruthyEnvValue(env.CAPGO_DISABLE_TELEMETRY) || isTruthyEnvValue(env.CAPGO_DISABLE_POSTHOG) if (telemetryDisabled && !payload.notifyConsole) return @@ -1920,7 +1906,7 @@ export async function sendEvent(capgkey: string, payload: TrackOptions & { notif // not bypass an Ink-controlled stdout (e.g. during `capgo init`). const config = await getRemoteConfig(true, signal) if (verbose) { - log.info(`Sending LogSnag event: ${JSON.stringify(enrichedPayload)}`) + log.info(`Sending analytics event: ${JSON.stringify(enrichedPayload)}`) } const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 seconds timeout @@ -1950,7 +1936,7 @@ export async function sendEvent(capgkey: string, payload: TrackOptions & { notif const response = await fetchResponse.json() as { error?: string } if (response.error && verbose) { - log.error(`Failed to send LogSnag event: ${response.error}`) + log.error(`Failed to send analytics event: ${response.error}`) } } finally { @@ -2918,7 +2904,7 @@ export async function promptAndSyncCapacitor( if (isCancel(shouldSync)) { // For init flow, mark the cancellation if (isInit && orgId && apikey) { - await markSnag('onboarding-v2', orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await sendCliEvent('onboarding-v2', orgId, apikey, 'canceled') } log.warn('Canceled Capacitor sync') throw new CliUserError('Capacitor sync cancelled') diff --git a/cli/test/init/browser-login.test.ts b/cli/test/init/browser-login.test.ts index 15335e5590..2dd9ed8374 100644 --- a/cli/test/init/browser-login.test.ts +++ b/cli/test/init/browser-login.test.ts @@ -107,7 +107,6 @@ describe('init browser login', () => { org_id: 'org-a', description: 'cli-login:AbCdEfGhIjKlMnOpQrStUv', notifyConsole: true, - notify: false, }) expect(output.join('\n')).toContain('/login-cli?session=AbCdEfGhIjKlMnOpQrStUv') expect(output.join('\n')).not.toContain('super-secret-key') diff --git a/cli/test/test-analytics.mjs b/cli/test/test-analytics.mjs index 63e3c3b5f5..ab030c1285 100644 --- a/cli/test/test-analytics.mjs +++ b/cli/test/test-analytics.mjs @@ -49,7 +49,9 @@ try { let body = JSON.parse(req.init.body) assert.equal(body.event, 'Test Event') assert.equal(body.channel, 'cli-usage') - assert.equal(body.notify, false) + assert.equal('icon' in body, false) + assert.equal('notify' in body, false) + assert.equal('parser' in body, false) assert.equal(body.org_id, 'org-1') assert.equal(body.tracking_version, 2) assert.equal(body.user_id, undefined, 'CLI must not send user_id (backend derives it)') @@ -66,11 +68,8 @@ try { await trackEvent({ apikey: 'capgo-key', channel: 'cli-usage', event: 'Nope', orgId: 'o', appId: 'a' }) await flushAnalytics() assert.equal(findEvent(requests), undefined, 'opt-out must suppress events') - delete process.env.CAPGO_DISABLE_TELEMETRY - // 4. console workflow events are functional delivery, not analytics - process.env.CAPGO_DISABLE_TELEMETRY = '1' - requests = stubFetch() + // 4. browser-login console events are functional delivery, not analytics await sendEvent('capgo-key', { channel: 'user-login', event: 'User CLI login', @@ -78,11 +77,29 @@ try { description: 'cli-login:test-session', tracking_version: 2, notifyConsole: true, - notify: false, }) - assert.ok(findEvent(requests), 'console workflow events must bypass analytics opt-out') + let consoleReq = findEvent(requests) + assert.ok(consoleReq, 'telemetry opt-out must preserve functional console broadcasts') + body = JSON.parse(consoleReq.init.body) + assert.equal(body.description, 'cli-login:test-session') + assert.equal(body.notifyConsole, true) delete process.env.CAPGO_DISABLE_TELEMETRY + process.env.CAPGO_DISABLE_POSTHOG = '1' + requests = stubFetch() + await sendEvent('capgo-key', { + channel: 'app', + event: 'App Created', + icon: '๐Ÿ†•', + notifyConsole: true, + }) + consoleReq = findEvent(requests) + assert.ok(consoleReq, 'PostHog opt-out must preserve functional console broadcasts') + body = JSON.parse(consoleReq.init.body) + assert.equal(body.icon, '๐Ÿ†•') + assert.equal(body.notifyConsole, true) + delete process.env.CAPGO_DISABLE_POSTHOG + // (the no-key early return is exercised in the migration suite; it can't be // simulated reliably here because the dev machine has a saved ~/.capgo) diff --git a/cli/test/test-init-telemetry.mjs b/cli/test/test-init-telemetry.mjs index c82130a850..6d6fb2da9e 100644 --- a/cli/test/test-init-telemetry.mjs +++ b/cli/test/test-init-telemetry.mjs @@ -10,7 +10,7 @@ const saved = { journey_id: 'ij_saved', last_run_id: 'ir_previous' } function create(options = {}) { const events = [] const telemetry = createInitTelemetry({ - capture: async (event, properties, icon, appId) => events.push({ event, properties, icon, appId }), + capture: async (event, properties, appId) => events.push({ event, properties, appId }), replaySessionId: () => 'init-replay', ...options, }) @@ -122,38 +122,22 @@ function assertBefore(source, first, second, message) { assert.deepEqual(events.map(event => event.event), ['onboarding-run-started', 'onboarding-run-ended'], 'lifecycle events are emitted once') } -{ - const { events, telemetry } = create() - await telemetry.recordMilestone('canceled', undefined, '๐Ÿคท') - assert.equal(events[0].icon, '๐Ÿคท', 'milestones preserve their icon through injected capture') -} - { const { events, telemetry } = create() telemetry.setScope('scoped-app') telemetry.setScope() - await telemetry.recordMilestone('event-scoped-milestone', undefined, 'โœ…', 'checked') + await telemetry.recordMilestone('event-scoped-milestone', undefined, 'checked') await telemetry.recordRunEnded('cancelled', 0) assert.deepEqual(events.map(event => event.appId), ['checked', 'scoped-app'], 'event scope does not replace the retained lifecycle app') } -{ - const icons = [] - for (const [outcome, code] of [['completed', 0], ['cancelled', 0], ['failed', 1]]) { - const { events, telemetry } = create() - await telemetry.recordRunEnded(outcome, code) - icons.push(events[0].icon) - } - assert.deepEqual(icons, ['โœ…', '๐Ÿคท', 'โŒ'], 'run endings use their outcome icon') -} - { const command = readFileSync(new URL('../src/init/command.ts', import.meta.url), 'utf8') const resume = command.slice(command.indexOf('async function tryResumeOnboarding'), command.indexOf('\nfunction cleanupStepsDone')) const markStepDone = command.slice(command.indexOf('function markStepDone'), command.indexOf('\ninterface ResumeResult')) const initApp = command.slice(command.indexOf('export async function initApp')) const createAppTemplate = command.slice(command.indexOf('async function runCreateAppTemplate'), command.indexOf('\nasync function ensureWorkspaceReadyForInit')) - const markInitSnag = command.slice(command.indexOf('async function markInitSnag'), command.indexOf('\nasync function markStep')) + const recordInitEvent = command.slice(command.indexOf('async function recordInitEvent'), command.indexOf('\nasync function markStep')) const exitAfterFinishingReplay = command.slice(command.indexOf('async function exitAfterFinishingReplay'), command.indexOf('\nconst frameworkSetupGuides')) const allExitCalls = [...command.matchAll(/(?= 0 && markStepDone.indexOf('mergeInitProgressTelemetry(progress, activeInitTelemetry?.getProgressMetadata())') >= 0, 'checkpoints merge telemetry into the existing operational progress payload') assert.ok(markStepDone.includes('formatError(error)'), 'progress reporting formats user-visible errors') - assert.ok(markInitSnag.includes('activeInitTelemetry?.setAuth(orgId, apikey)'), 'classic milestones set active authentication') - assert.ok(!markInitSnag.includes('setScope('), 'classic milestones do not persist event-only app associations') - assert.ok(markInitSnag.includes('activeInitTelemetry.recordMilestone(event, undefined, icon, appId ?? null)'), 'classic milestones use the telemetry context and preserve their app and icon') - assert.ok(markInitSnag.includes("return markSnag('onboarding-v2', orgId, apikey, event, appId, icon"), 'classic milestones retain the isolated markSnag fallback') + assert.ok(recordInitEvent.includes('activeInitTelemetry?.setAuth(orgId, apikey)'), 'classic milestones set active authentication') + assert.ok(!recordInitEvent.includes('setScope('), 'classic milestones do not persist event-only app associations') + assert.ok(recordInitEvent.includes('activeInitTelemetry.recordMilestone(event, undefined, appId ?? null)'), 'classic milestones use the telemetry context and preserve their app') + assert.ok(recordInitEvent.includes("return sendCliEvent('onboarding-v2', orgId, apikey, event, appId"), 'classic milestones retain the isolated sendCliEvent fallback') assert.equal(exitCalls.length, allExitCalls.length, 'every shared exit has an explicit outcome and code') assert.ok(exitCalls.every(([, outcome, code]) => outcome === 'cancelled' || (outcome === 'completed' ? code === '0' : code === '1')), 'shared exits use valid outcome/code pairs') assert.equal(exitCalls.filter(([, outcome]) => outcome === 'completed').length, 1, 'only final onboarding completion is completed') diff --git a/cli/test/test-onboarding-telemetry.mjs b/cli/test/test-onboarding-telemetry.mjs index adafb31a71..93710070e5 100644 --- a/cli/test/test-onboarding-telemetry.mjs +++ b/cli/test/test-onboarding-telemetry.mjs @@ -93,7 +93,9 @@ try { const body = findEventBody(requests) assert.equal(body.event, 'Builder Onboarding Action') assert.equal(body.channel, 'builder-onboarding') - assert.equal(body.notify, false) + assert.equal('icon' in body, false) + assert.equal('notify' in body, false) + assert.equal('parser' in body, false) assert.equal(body.org_id, 'org-id') assert.equal(body.tracking_version, 2) assert.deepEqual(body.tags, { @@ -157,8 +159,9 @@ try { const body = findEventBody(requests) assert.equal(body.event, 'Builder Onboarding Quit') assert.equal(body.channel, 'builder-onboarding') - assert.equal(body.icon, '๐Ÿšช') - assert.equal(body.notify, false) + assert.equal('icon' in body, false) + assert.equal('notify' in body, false) + assert.equal('parser' in body, false) assert.equal(body.org_id, 'org-id') assert.equal(body.tracking_version, 2) assert.deepEqual(body.tags, { diff --git a/cli/test/test-v2-event-migration.mjs b/cli/test/test-v2-event-migration.mjs index 4e3e115e4b..6cc851bb54 100644 --- a/cli/test/test-v2-event-migration.mjs +++ b/cli/test/test-v2-event-migration.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node import assert from 'node:assert/strict' -import { markSnag } from '../src/app/debug.ts' +import { sendCliEvent } from '../src/app/debug.ts' -console.log('๐Ÿงช Testing v2 event migration (markSnag โ†’ onboarding events)...\n') +console.log('๐Ÿงช Testing v2 event migration (sendCliEvent โ†’ onboarding events)...\n') const originalFetch = globalThis.fetch @@ -18,24 +18,26 @@ try { }) } - // markSnag is the shared helper behind every onboarding-step-* / debug event. - await markSnag('onboarding-v2', 'org-123', 'capgo-key', 'onboarding-step-done', 'com.example.app') + // sendCliEvent is the shared helper behind every onboarding-step-* / debug event. + await sendCliEvent('onboarding-v2', 'org-123', 'capgo-key', 'onboarding-step-done', 'com.example.app') const eventRequest = requests.find(request => request.url.endsWith('/private/events')) - assert.ok(eventRequest, 'Expected markSnag telemetry request') + assert.ok(eventRequest, 'Expected sendCliEvent telemetry request') assert.equal(eventRequest.init.method, 'POST') assert.equal(eventRequest.init.headers.capgkey, 'capgo-key') const body = JSON.parse(eventRequest.init.body) assert.equal(body.event, 'onboarding-step-done') assert.equal(body.channel, 'onboarding-v2') - assert.equal(body.notify, false) + assert.equal('icon' in body, false) + assert.equal('notify' in body, false) + assert.equal('parser' in body, false) assert.equal(body.org_id, 'org-123', 'org is now sent as org_id (not user_id)') assert.equal(body.tracking_version, 2, 'event opts into the v2 actor-scoped contract') assert.equal(body.user_id, undefined, 'CLI must not send user_id (backend derives the actor from the key)') // Global analytics props now ride in nonPersonTags (event-only; the backend // never writes them as PostHog person properties / $set). Caller tags stay in - // tags โ€” markSnag's direct (non-trackEvent) send path included. + // tags โ€” sendCliEvent's direct (non-trackEvent) send path included. assert.equal(typeof body.nonPersonTags.os_release, 'string', 'OS release rides on the shared send path') assert.equal(typeof body.nonPersonTags.os_platform, 'string') assert.equal(typeof body.nonPersonTags.os_arch, 'string') diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 2c4b988945..66547b3ef0 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -68,7 +68,7 @@ import { app as cron_rollout_auto_pause } from '../../supabase/functions/_backen import { app as cron_stat_app } from '../../supabase/functions/_backend/triggers/cron_stat_app.ts' import { app as cron_stat_org } from '../../supabase/functions/_backend/triggers/cron_stat_org.ts' import { app as cron_sync_sub } from '../../supabase/functions/_backend/triggers/cron_sync_sub.ts' -import { app as logsnag_insights, logsnagInsightsLegacyUsageApp, logsnagInsightsShardApps } from '../../supabase/functions/_backend/triggers/logsnag_insights.ts' +import { app as global_stats, globalStatsLegacyUsageApp, globalStatsShardApps } from '../../supabase/functions/_backend/triggers/global_stats.ts' import { app as on_app_create } from '../../supabase/functions/_backend/triggers/on_app_create.ts' import { app as on_app_delete } from '../../supabase/functions/_backend/triggers/on_app_delete.ts' import { app as on_app_update } from '../../supabase/functions/_backend/triggers/on_app_update.ts' @@ -173,23 +173,24 @@ appTriggers.route('/cron_clear_versions', cron_clear_versions) appTriggers.route('/cron_clean_orphan_images', cron_clean_orphan_images) appTriggers.route('/cron_reconcile_build_status', cron_reconcile_build_status) appTriggers.route('/credit_usage_alerts', credit_usage_alerts) -appTriggers.route('/logsnag_insights', logsnag_insights) -appTriggers.route('/logsnag_insights_core', logsnagInsightsShardApps.core) -appTriggers.route('/logsnag_insights_usage', logsnagInsightsLegacyUsageApp) -appTriggers.route('/logsnag_insights_usage_updates', logsnagInsightsShardApps.usage_updates) -appTriggers.route('/logsnag_insights_usage_devices', logsnagInsightsShardApps.usage_devices) -appTriggers.route('/logsnag_insights_usage_device_platforms', logsnagInsightsShardApps.usage_device_platforms) -appTriggers.route('/logsnag_insights_usage_registrations', logsnagInsightsShardApps.usage_registrations) -appTriggers.route('/logsnag_insights_usage_storage', logsnagInsightsShardApps.usage_storage) -appTriggers.route('/logsnag_insights_usage_success_rate', logsnagInsightsShardApps.usage_success_rate) -appTriggers.route('/logsnag_insights_usage_demo_apps', logsnagInsightsShardApps.usage_demo_apps) -appTriggers.route('/logsnag_insights_revenue', logsnagInsightsShardApps.revenue) -appTriggers.route('/logsnag_insights_plugins', logsnagInsightsShardApps.plugins) -appTriggers.route('/logsnag_insights_builds', logsnagInsightsShardApps.builds) -appTriggers.route('/logsnag_insights_retention', logsnagInsightsShardApps.retention) -appTriggers.route('/logsnag_insights_paid_products', logsnagInsightsShardApps.paid_products) -appTriggers.route('/logsnag_insights_ltv', logsnagInsightsShardApps.ltv) -appTriggers.route('/logsnag_insights_notifications', logsnagInsightsShardApps.notifications) +appTriggers.route('/global_stats', global_stats) +appTriggers.route('/global_stats_core', globalStatsShardApps.core) +appTriggers.route('/global_stats_usage', globalStatsLegacyUsageApp) +appTriggers.route('/global_stats_usage_updates', globalStatsShardApps.usage_updates) +appTriggers.route('/global_stats_usage_devices', globalStatsShardApps.usage_devices) +appTriggers.route('/global_stats_usage_device_platforms', globalStatsShardApps.usage_device_platforms) +appTriggers.route('/global_stats_usage_registrations', globalStatsShardApps.usage_registrations) +appTriggers.route('/global_stats_usage_storage', globalStatsShardApps.usage_storage) +appTriggers.route('/global_stats_usage_success_rate', globalStatsShardApps.usage_success_rate) +appTriggers.route('/global_stats_usage_demo_apps', globalStatsShardApps.usage_demo_apps) +appTriggers.route('/global_stats_revenue', globalStatsShardApps.revenue) +appTriggers.route('/global_stats_plugins', globalStatsShardApps.plugins) +appTriggers.route('/global_stats_builds', globalStatsShardApps.builds) +appTriggers.route('/global_stats_retention', globalStatsShardApps.retention) +appTriggers.route('/global_stats_paid_products', globalStatsShardApps.paid_products) +appTriggers.route('/global_stats_ltv', globalStatsShardApps.ltv) +appTriggers.route('/global_stats_notifications', globalStatsShardApps.notifications) +appTriggers.route('/global_stats_native_notifications', globalStatsShardApps.native_notifications) appTriggers.route('/on_channel_update', on_channel_update) appTriggers.route('/on_app_create', on_app_create) appTriggers.route('/on_app_delete', on_app_delete) diff --git a/deno.lock b/deno.lock index 8b44761f10..f05c44b17c 100644 --- a/deno.lock +++ b/deno.lock @@ -125,7 +125,6 @@ "npm:@jsr/bradenmacdonald__s3-lite-client@0.9.6": "0.9.6", "npm:@jsr/sauber__table@*": "0.1.0", "npm:@jsr/std__semver@1.0.8": "1.0.8", - "npm:@logsnag/node@1.0.1": "1.0.1", "npm:@modelcontextprotocol/sdk@^1.29.0": "1.29.0_zod@4.4.3", "npm:@playwright/test@1.61.0": "1.61.0", "npm:@rrweb/types@^2.0.1": "2.0.1", @@ -2643,9 +2642,6 @@ "@kurkle/color@0.3.4": { "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" }, - "@logsnag/node@1.0.1": { - "integrity": "sha512-JW2S1KN91XyOb0oG2PblboZ1Ys4mkOSMn83GDYjM8CXzcFbkYFMnlFQoEgP0Y5z+1A56hOO+a7uLsvxO5IdUFA==" - }, "@mediapipe/tasks-genai@0.10.27": { "integrity": "sha512-cv69CPPAtEDBUs6dGZft2S+sBqde1XvEMST367siSyxrhffdWtm4uQIsfdedAbhJ33BwAjuMnAdxDrO9WrzIAQ==" }, @@ -9668,7 +9664,6 @@ "npm:@intlify/unplugin-vue-i18n@^11.2.3", "npm:@jsr/bradenmacdonald__s3-lite-client@0.9.6", "npm:@jsr/std__semver@1.0.8", - "npm:@logsnag/node@1.0.1", "npm:@playwright/test@1.61.0", "npm:@standard-schema/spec@^1.1.0", "npm:@supabase/supabase-js@2.108.2", diff --git a/docs/superpowers/plans/2026-08-13-onboarding-copy-analytics.md b/docs/superpowers/plans/2026-08-13-onboarding-copy-analytics.md index 8dedaef8db..9dd72f1377 100644 --- a/docs/superpowers/plans/2026-08-13-onboarding-copy-analytics.md +++ b/docs/superpowers/plans/2026-08-13-onboarding-copy-analytics.md @@ -289,7 +289,7 @@ git commit -m "feat(onboarding): copy AI instructions with API key" Add a test proving `posthog: false` skips only PostHog: ```ts -it('can skip PostHog while preserving LogSnag and Bento delivery', async () => { +it('can skip PostHog while preserving Bento delivery', async () => { const { sendEventToTracking } = await import('../supabase/functions/_backend/utils/tracking.ts') await sendEventToTracking(createContext(), { @@ -306,7 +306,6 @@ it('can skip PostHog while preserving LogSnag and Bento delivery', async () => { user_id: 'org-id', }, { background: false, posthog: false }) - expect(logsnagTrackMock).toHaveBeenCalledOnce() expect(posthogMock).not.toHaveBeenCalled() expect(notifToOrgMembersMock).toHaveBeenCalledOnce() }) @@ -336,8 +335,8 @@ export interface SendEventToTrackingOptions { } ``` -Build the tracking task list with LogSnag always present and PostHog added only -when `options.posthog !== false`. Do not change Bento execution or defaults. +Run PostHog only when `options.posthog !== false`. Do not change Bento execution +or defaults. - [ ] **Step 4: Run the focused tracking test** diff --git a/docs/superpowers/specs/2026-05-18-capgo-builder-posthog-tracking-design.md b/docs/superpowers/specs/2026-05-18-capgo-builder-posthog-tracking-design.md index 3aafd35c2e..d30d6e85ed 100644 --- a/docs/superpowers/specs/2026-05-18-capgo-builder-posthog-tracking-design.md +++ b/docs/superpowers/specs/2026-05-18-capgo-builder-posthog-tracking-design.md @@ -20,7 +20,7 @@ Mirror the existing Capgo onboarding-progress PostHog tracking onto the **Capgo ### 1. Onboarding step events -One event per CLI wizard step transition. Sent from the CLI through the existing `/private/events` endpoint so the existing dual-writer (LogSnag + PostHog) and org grouping apply automatically. +One event per CLI wizard step transition. Sent from the CLI through the existing `/private/events` endpoint so PostHog capture and org grouping apply automatically. **Event:** `Builder Onboarding Step` **Channel:** `builder-onboarding` @@ -158,7 +158,6 @@ ONBOARDING: โ””โ”€โ†’ POST /private/events [reuses existing endpoint] โ””โ”€โ†’ backend validates body, resolves orgId via resolveTrackingUserId โ””โ”€โ†’ sendEventToTracking(...) [supabase/functions/_backend/utils/tracking.ts] - โ”œโ”€โ†’ logsnag(c).track(...) โ””โ”€โ†’ trackPosthogEvent(c, {...}) BUILDS: @@ -183,7 +182,7 @@ BUILDS: The CLI already has `capgo/cli/src/posthog.ts`, but it is scoped to exception capture (`$exception` events with stack traces). Routing onboarding events through the backend gives us: - Org grouping for free (`groups: { organization: orgId }`) without the CLI having to know the org id -- Dual-write to LogSnag (existing convention) +- Centralized PostHog delivery and error handling - Auth-gated event source (anyone with a CLI token is a real user) - Consistency with `on_app_create.ts` and the other backend trackers diff --git a/docs/superpowers/specs/2026-05-30-builder-cta-on-incompatible-upload-design.md b/docs/superpowers/specs/2026-05-30-builder-cta-on-incompatible-upload-design.md index b1f3ffe992..95b172b22c 100644 --- a/docs/superpowers/specs/2026-05-30-builder-cta-on-incompatible-upload-design.md +++ b/docs/superpowers/specs/2026-05-30-builder-cta-on-incompatible-upload-design.md @@ -122,7 +122,7 @@ upload is abandoned before any zip/upload work: ## Tracking (PostHog via existing `trackEvent`) All events go through `cli/src/analytics/track.ts` โ†’ `sendEvent` โ†’ `/private/events` -โ†’ PostHog (and LogSnag). They carry the standard global props (cli_version, +โ†’ PostHog. They carry the standard global props (cli_version, node_version, os, is_ci, app_id, org group) automatically. | Event | Tags | diff --git a/docs/superpowers/specs/2026-08-13-onboarding-copy-analytics-design.md b/docs/superpowers/specs/2026-08-13-onboarding-copy-analytics-design.md index 3a31050df5..d743f390d6 100644 --- a/docs/superpowers/specs/2026-08-13-onboarding-copy-analytics-design.md +++ b/docs/superpowers/specs/2026-08-13-onboarding-copy-analytics-design.md @@ -71,7 +71,7 @@ event name and safe metadata through the existing authenticated The backend recognizes this exact event as an allowlisted frontend-captured event. It builds a server-owned Bento payload and sends it through the existing Bento tracking path, while disabling the backend PostHog provider for this -request. LogSnag may continue to receive the backend event. +request. Bento delivery still runs for the allowlisted event. Implement backend PostHog suppression as an internal option on the shared tracking dispatcher. The client cannot select arbitrary providers: the diff --git a/docs/superpowers/specs/2026-08-14-cli-init-onboarding-telemetry-identities-design.md b/docs/superpowers/specs/2026-08-14-cli-init-onboarding-telemetry-identities-design.md index f10ba5ac8c..d163f23d67 100644 --- a/docs/superpowers/specs/2026-08-14-cli-init-onboarding-telemetry-identities-design.md +++ b/docs/superpowers/specs/2026-08-14-cli-init-onboarding-telemetry-identities-design.md @@ -55,5 +55,5 @@ their names and success boundaries, gaining the shared identity properties. ## Delivery Lifecycle and enriched milestone events keep the existing best-effort -`markSnag`/`sendEvent` path. No queue, retry, timeout, flush, transport, +`sendCliEvent`/`sendEvent` path. No queue, retry, timeout, flush, transport, backend, replay-delivery, or control-flow changes are part of this work. diff --git a/package.json b/package.json index 28c7754de7..f867f7e34d 100644 --- a/package.json +++ b/package.json @@ -316,7 +316,6 @@ "@formkit/themes": "2.1.0", "@formkit/vue": "2.1.0", "@hono/standard-validator": "^0.3.0", - "@logsnag/node": "1.0.1", "@std/semver": "npm:@jsr/std__semver@1.0.8", "@supabase/supabase-js": "2.110.8", "@vuepic/vue-datepicker": "^14.0.0", diff --git a/scripts/backfill_org_conversion_rate_trend.ts b/scripts/backfill_org_conversion_rate_trend.ts index fe31a3d553..aa104456bb 100644 --- a/scripts/backfill_org_conversion_rate_trend.ts +++ b/scripts/backfill_org_conversion_rate_trend.ts @@ -6,7 +6,7 @@ * that denominator from public.orgs.created_at. * * plan_*_conversion_rate = plan_count / paying * 100 - * Plan mix among paying orgs (same as the daily admin core shard / LogSnag). + * Plan mix among paying orgs (same as the daily admin core shard). * * Dry run, defaulting to the last 30 UTC calendar days: * bun run stripe:backfill-org-conversion-rate diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 7914c4656e..9849f8b677 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -1816,9 +1816,7 @@ function trackSuccessfulCopy(event: OnboardingCopyEvent) { void sendEvent({ channel: 'onboarding', event, - icon: '๐Ÿค–', nonPersonTags: properties, - notify: false, org_id: orgId, tags: { app_id: appId }, tracking_version: 2, diff --git a/src/components/dashboard/DemoOnboardingModal.vue b/src/components/dashboard/DemoOnboardingModal.vue index e338bcaaa9..d3dac2f9fa 100644 --- a/src/components/dashboard/DemoOnboardingModal.vue +++ b/src/components/dashboard/DemoOnboardingModal.vue @@ -195,10 +195,8 @@ function trackNoAppDemoEvent(event: string, tags: Record /** Tag Type */ type Tags = Record -type Parser = 'markdown' | 'text' /** - * Options for publishing LogSnag events + * Options for publishing analytics events */ interface TrackOptions { /** @@ -36,12 +35,6 @@ interface TrackOptions { * Tracking payload contract version. */ tracking_version?: number - /** - * Event icon (emoji) - * must be a single emoji - * example: "๐ŸŽ‰" - */ - icon?: string /** * Event tags * example: { username: "mattie" } @@ -51,14 +44,6 @@ interface TrackOptions { * Per-event metadata that must not become PostHog person properties. */ nonPersonTags?: Tags - /** - * Send push notification - */ - notify?: boolean - /** - * Parser for description - */ - parser?: Parser /** * Event timestamp */ diff --git a/supabase/functions/.env b/supabase/functions/.env index fa7f036c5e..60c422345a 100644 --- a/supabase/functions/.env +++ b/supabase/functions/.env @@ -24,9 +24,6 @@ STRIPE_WEBHOOK_SECRET=test STRIPE_SECRET_KEY=test # Sentry if omitted, errors will not be sent SENTRY_DSN= -# LogsNag if omitted, admin stats will not be sent -LOGSNAG_TOKEN= -LOGSNAG_PROJECT= # Bento to connect email service and send marketing and transactional emails BENTO_PUBLISHABLE_KEY= BENTO_SECRET_KEY= diff --git a/supabase/functions/.env.example b/supabase/functions/.env.example index 3fdc013ff5..179341c88b 100644 --- a/supabase/functions/.env.example +++ b/supabase/functions/.env.example @@ -24,9 +24,6 @@ STRIPE_WEBHOOK_SECRET=test STRIPE_SECRET_KEY=test # Sentry if omitted, errors will not be sent SENTRY_DSN= -# LogsNag if omitted, admin stats will not be sent -LOGSNAG_TOKEN= -LOGSNAG_PROJECT= # Bento to connect email service and send marketing and transactional emails BENTO_PUBLISHABLE_KEY= BENTO_SECRET_KEY= diff --git a/supabase/functions/_backend/plugin_runtime/utils/posthog.ts b/supabase/functions/_backend/plugin_runtime/utils/posthog.ts index 42deb4941e..f62c8eb4ad 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/posthog.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/posthog.ts @@ -1,4 +1,3 @@ -import type { TrackOptions } from '@logsnag/node' import type { Context } from 'hono' import { cloudlog, cloudlogErr, serializeError } from './logging.ts' import { existInEnv, getEnv, trimTrailingSlashes } from './utils.ts' @@ -10,8 +9,11 @@ const POSTHOG_DELIVERY_TIMEOUT_MS = 5000 export type PostHogGroups = Record -interface PostHogCapturePayload extends Pick, Pick { +interface PostHogCapturePayload { + channel: string + description?: string distinct_id?: string + event: string groups?: PostHogGroups ip?: string setPersonProperties?: boolean diff --git a/supabase/functions/_backend/private/delete_failed_version.ts b/supabase/functions/_backend/private/delete_failed_version.ts index eddad5ab40..12e73e21e4 100644 --- a/supabase/functions/_backend/private/delete_failed_version.ts +++ b/supabase/functions/_backend/private/delete_failed_version.ts @@ -91,7 +91,6 @@ app.delete('/', middlewareKey(), async (c) => { event: 'Failed to upload a bundle', user_id: version.owner_org, groups: { organization: version.owner_org }, - icon: '๐Ÿ’€', }) cloudlog({ requestId: c.get('requestId'), message: 'delete version', id: version.id }) diff --git a/supabase/functions/_backend/private/events.ts b/supabase/functions/_backend/private/events.ts index f7f8087996..9115bc55f8 100644 --- a/supabase/functions/_backend/private/events.ts +++ b/supabase/functions/_backend/private/events.ts @@ -1,7 +1,6 @@ -import type { TrackOptions } from '@logsnag/node' import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../utils/hono.ts' -import type { BentoTrackingPayload } from '../utils/tracking.ts' +import type { BentoTrackingPayload, TrackOptions } from '../utils/tracking.ts' import { Hono } from 'hono/tiny' import { APP_TOO_LARGE_EVENT, buildAppTooLargeBentoEvent } from '../utils/app_too_large_tracking.ts' import { buildBuilderOnboardingBentoEvent, BUILDER_RECOVERY_MILESTONES } from '../utils/builder_onboarding_recovery.ts' @@ -35,8 +34,13 @@ interface ResolvedTrackingId { } interface TrackEventBody extends TrackOptions { + // Older clients may still send these fields. They are discarded for + // analytics; icon is read only by the explicit Realtime console path. + icon?: string + notify?: boolean notifyConsole?: boolean org_id?: string + parser?: 'markdown' | 'text' tracking_version?: number | string nonPersonTags?: Record } @@ -152,7 +156,7 @@ function buildTrackedBody( verifiedOrgId: string | undefined, requestedUserId: string | undefined, trackingUserId: string, - trackOptions: Omit, + trackOptions: TrackOptions, ) { const trackedTags = trackingV2 && verifiedOrgId ? { ...(trackOptions.tags || {}), org_id: verifiedOrgId } @@ -167,6 +171,7 @@ function buildTrackedBody( async function handleNotifyConsole( c: Context, trackedBody: TrackOptions, + icon: string | undefined, appId: string | undefined, verifiedOrgId: string | undefined, ) { @@ -177,7 +182,7 @@ async function handleNotifyConsole( event: trackedBody.event, channel: trackedBody.channel, description: trackedBody.description, - icon: trackedBody.icon, + icon, app_id: appId, org_id: verifiedOrgId, channel_name: typeof trackedBody.tags?.channel === 'string' ? trackedBody.tags.channel : undefined, @@ -394,7 +399,15 @@ async function buildBundleIncompatibleBentoEvent( app.post('/', middlewareAuth(), async (c) => { const body = await parseBody(c) - const { notifyConsole = false, org_id: _orgId, tracking_version: _trackingVersion, ...trackOptions } = body + const { + icon, + notify: _notify, + notifyConsole = false, + org_id: _orgId, + parser: _parser, + tracking_version: _trackingVersion, + ...trackOptions + } = body const trackingV2 = isTrackingV2(body.tracking_version) const requestedOrgId = getRequestedOrgId(body, trackingV2) const requestedUserId = typeof body.user_id === 'string' ? body.user_id : undefined @@ -404,7 +417,7 @@ app.post('/', middlewareAuth(), async (c) => { // notifyConsole: broadcast to Supabase Realtime only, skip all tracking if (notifyConsole) { - await handleNotifyConsole(c, trackedBody, appId, verifiedOrgId) + await handleNotifyConsole(c, trackedBody, icon, appId, verifiedOrgId) return c.json(BRES) } diff --git a/supabase/functions/_backend/private/upload_link.ts b/supabase/functions/_backend/private/upload_link.ts index d53ebc575e..3815da6f8c 100644 --- a/supabase/functions/_backend/private/upload_link.ts +++ b/supabase/functions/_backend/private/upload_link.ts @@ -76,10 +76,8 @@ app.post('/', middlewareKey(), async (c) => { await sendEventToTracking(c, { channel: 'upload-get-link', event: 'Upload via single file', - icon: '๐Ÿ›๏ธ', user_id: app.owner_org, groups: { organization: app.owner_org }, - notify: false, }) cloudlog({ requestId: c.get('requestId'), message: 'upload link generated', filePath }) diff --git a/supabase/functions/_backend/public/build/ai_analyze_stream.ts b/supabase/functions/_backend/public/build/ai_analyze_stream.ts index cc06d5a85e..694deaa3e0 100644 --- a/supabase/functions/_backend/public/build/ai_analyze_stream.ts +++ b/supabase/functions/_backend/public/build/ai_analyze_stream.ts @@ -123,8 +123,6 @@ export async function aiAnalyzeStreamBuild( await sendEventToTracking(c, { event: 'AI Build Analysis Requested', channel: 'build-lifecycle', - icon: '๐Ÿค–', - notify: false, user_id: apikey.user_id, groups: { organization: ownerOrg }, tags: { app_id: appId, org_id: ownerOrg, job_id: jobId, logs_bytes: String(logsBytes) }, diff --git a/supabase/functions/_backend/public/build/ai_analyze_telemetry.ts b/supabase/functions/_backend/public/build/ai_analyze_telemetry.ts index abc8c0813c..4e6c141f08 100644 --- a/supabase/functions/_backend/public/build/ai_analyze_telemetry.ts +++ b/supabase/functions/_backend/public/build/ai_analyze_telemetry.ts @@ -49,8 +49,6 @@ export async function emitAiAnalysisResult(c: Context, input: EmitAiAnalysisResu await sendEventToTracking(c, { event: 'AI Build Analysis Result', channel: 'build-lifecycle', - icon: '๐Ÿค–', - notify: false, user_id: input.userId, groups: input.ownerOrg ? { organization: input.ownerOrg } : undefined, tags, diff --git a/supabase/functions/_backend/public/build/concurrency.ts b/supabase/functions/_backend/public/build/concurrency.ts index 40ac3a9223..7f2c213f45 100644 --- a/supabase/functions/_backend/public/build/concurrency.ts +++ b/supabase/functions/_backend/public/build/concurrency.ts @@ -76,10 +76,8 @@ export async function notifyNativeBuildConcurrencyLimit( await sendEventToTracking(c, { channel: 'usage', event: 'Native build concurrency limit reached', - icon: '๐Ÿšง', user_id: input.userId || input.orgId, groups: { organization: input.orgId }, - notify: false, tags: { org_id: input.orgId, ...(input.appId ? { app_id: input.appId } : {}), diff --git a/supabase/functions/_backend/public/build/request.ts b/supabase/functions/_backend/public/build/request.ts index 42de001a48..215d429fd9 100644 --- a/supabase/functions/_backend/public/build/request.ts +++ b/supabase/functions/_backend/public/build/request.ts @@ -406,8 +406,6 @@ async function recordBuildRequestedTelemetry(c: Context, input: { await sendEventToTracking(c, { event: 'Build Requested', channel: 'build-lifecycle', - icon: '๐Ÿ› ๏ธ', - notify: false, user_id: input.user_id, groups: { organization: input.org_id }, tags: { diff --git a/supabase/functions/_backend/triggers/canceled_org_retention_alerts.ts b/supabase/functions/_backend/triggers/canceled_org_retention_alerts.ts index cefc9fd8a1..319506254c 100644 --- a/supabase/functions/_backend/triggers/canceled_org_retention_alerts.ts +++ b/supabase/functions/_backend/triggers/canceled_org_retention_alerts.ts @@ -20,17 +20,14 @@ const ALERT_CONFIG = { bundles_deletion_warning: { bentoEvent: 'org:bundles_will_be_deleted', trackingEvent: 'Bundles will be deleted', - icon: '๐Ÿ“ฆ', }, app_deletion_warning: { bentoEvent: 'org:apps_will_be_deleted', trackingEvent: 'Apps will be deleted', - icon: '๐Ÿ—‘๏ธ', }, } as const satisfies Record const ORG_ID_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -114,10 +111,8 @@ app.post('/', middlewareAPISecret, async (c) => { }, channel: 'usage', event: config.trackingEvent, - icon: config.icon, user_id: orgId, groups: { organization: orgId }, - notify: false, sentToBento: true, tags: { alert_type: alertType, diff --git a/supabase/functions/_backend/triggers/credit_usage_alerts.ts b/supabase/functions/_backend/triggers/credit_usage_alerts.ts index 97c23c7673..795a44ce51 100644 --- a/supabase/functions/_backend/triggers/credit_usage_alerts.ts +++ b/supabase/functions/_backend/triggers/credit_usage_alerts.ts @@ -75,10 +75,8 @@ app.post('/', middlewareAPISecret, async (c) => { await sendEventToTracking(c, { channel: 'usage', event: `Credit usage ${threshold}%+`, - icon: 'โšก๏ธ', user_id: orgId, groups: { organization: orgId }, - notify: threshold >= 100, tags: { alert_cycle: alertCycle.toString(), percent_used: percentUsed.toFixed(2), diff --git a/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts index a22b569bd9..3f2f0aec23 100644 --- a/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts +++ b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts @@ -140,7 +140,6 @@ async function evaluateChannel(c: Parameters[0], supabase: await sendEventToTracking(c, { channel: 'rollout-auto-pause', event: 'Rollout Auto-Pause Notification', - icon: 'โš ๏ธ', user_id: channel.owner_org, groups: { organization: channel.owner_org }, tags: { @@ -150,7 +149,6 @@ async function evaluateChannel(c: Parameters[0], supabase: rollout_version: versionName, }, description: reason, - notify: true, }, { background: false }) } diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/global_stats.ts similarity index 89% rename from supabase/functions/_backend/triggers/logsnag_insights.ts rename to supabase/functions/_backend/triggers/global_stats.ts index 827f5c6c73..82d097679c 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/global_stats.ts @@ -9,7 +9,6 @@ import { getDeviceDaySuccessRateCF, getLastMonthAnalyticsWindowStart, getPluginB import { GLOBAL_STATS_SHARDS, REQUIRED_GLOBAL_STATS_SHARDS, USAGE_GLOBAL_STATS_SHARDS } from '../utils/global_stats.ts' import { BRES, middlewareAPISecret, quickError } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' -import { logsnagInsights } from '../utils/logsnag.ts' import { readGlobalNotificationStatsCF } from '../utils/nativeNotifications.ts' import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' import { countAllApps, countAllUpdates, countAllUpdatesExternal } from '../utils/stats.ts' @@ -421,21 +420,18 @@ function normalizeCoreSnapshotCounts(row: Partial | null | unde } } -const LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES = 4 -const LOGSNAG_INSIGHTS_RETRY_DELAY_SECONDS = 300 -const LOGSNAG_INSIGHTS_QUEUE_NAME = 'admin_stats' -const LOGSNAG_INSIGHTS_NOTIFICATION_DELAY_SECONDS = 180 -const LOGSNAG_INSIGHTS_RECENT_REPAIR_LOOKBACK_DAYS = 30 +const GLOBAL_STATS_BACKGROUND_MAX_RETRIES = 4 +const GLOBAL_STATS_RETRY_DELAY_SECONDS = 300 +const GLOBAL_STATS_QUEUE_NAME = 'admin_stats' +const GLOBAL_STATS_NOTIFICATION_DELAY_SECONDS = 180 +const GLOBAL_STATS_RECENT_REPAIR_LOOKBACK_DAYS = 30 +// Keep the legacy key so old and new workers contend on the same lock during rolling deployments. const GLOBAL_STATS_NOTIFICATION_LOCK_NAMESPACE = 'logsnag_insights_notifications' -const GLOBAL_STATS_NOTIFICATION_LOGSNAG_STEP = 'notifications_logsnag' const GLOBAL_STATS_NOTIFICATION_TRACKING_STEP = 'notifications_tracking' -const GLOBAL_STATS_NOTIFICATION_LOGSNAG_CLAIM = 'notifications_logsnag_claim' const GLOBAL_STATS_NOTIFICATION_TRACKING_CLAIM = 'notifications_tracking_claim' const GLOBAL_STATS_COMPLETION_MARKERS = [ ...GLOBAL_STATS_SHARDS, - GLOBAL_STATS_NOTIFICATION_LOGSNAG_STEP, GLOBAL_STATS_NOTIFICATION_TRACKING_STEP, - GLOBAL_STATS_NOTIFICATION_LOGSNAG_CLAIM, GLOBAL_STATS_NOTIFICATION_TRACKING_CLAIM, ] as const const GLOBAL_STATS_SHARD_SET = new Set(GLOBAL_STATS_SHARDS) @@ -460,7 +456,7 @@ type GlobalStatsSnapshotRow = GlobalStatsRow & { plan_credits?: number | null } -interface LogsnagInsightsPayload { +interface GlobalStatsPayload { retry_count?: unknown shard?: unknown date_id?: unknown @@ -498,29 +494,29 @@ interface GlobalStatsRepairSqlRow { build_count_day_android: number | string | null } -interface ScheduleLogsnagInsightsUpdateOptions { +interface ScheduleGlobalStatsUpdateOptions { retryCount?: number retryMsgId?: number | null cancelRetry?: (c: Context, retryMsgId: number) => Promise } -interface ScheduleLogsnagInsightsShardOptions { +interface ScheduleGlobalStatsShardOptions { retryCount?: number retryMsgId?: number | null cancelRetry?: (c: Context, retryMsgId: number) => Promise runShard?: (c: Context, shard: GlobalStatsShard, dateId: string) => Promise } -function normalizeLogsnagInsightsRetryCount(value: unknown): number { +function normalizeGlobalStatsRetryCount(value: unknown): number { const retryCount = Number(value) if (!Number.isFinite(retryCount) || retryCount < 0) return 0 return Math.floor(retryCount) } -function buildLogsnagInsightsRetryMessage(retryCount: number, dateId?: string) { +function buildGlobalStatsRetryMessage(retryCount: number, dateId?: string) { return { - function_name: 'logsnag_insights', + function_name: 'global_stats', function_type: 'cloudflare', payload: { ...(dateId ? { date_id: dateId } : {}), @@ -529,13 +525,13 @@ function buildLogsnagInsightsRetryMessage(retryCount: number, dateId?: string) { } } -function getLogsnagInsightsShardFunctionName(shard: GlobalStatsShard): string { - return `logsnag_insights_${shard}` +function getGlobalStatsShardFunctionName(shard: GlobalStatsShard): string { + return `global_stats_${shard}` } -function buildLogsnagInsightsShardMessage(shard: GlobalStatsShard, dateId: string, retryCount = 0) { +function buildGlobalStatsShardMessage(shard: GlobalStatsShard, dateId: string, retryCount = 0) { return { - function_name: getLogsnagInsightsShardFunctionName(shard), + function_name: getGlobalStatsShardFunctionName(shard), function_type: 'cloudflare', payload: { date_id: dateId, @@ -544,7 +540,7 @@ function buildLogsnagInsightsShardMessage(shard: GlobalStatsShard, dateId: strin } } -async function readLogsnagInsightsPayload(c: Context): Promise { +async function readGlobalStatsPayload(c: Context): Promise { const rawBody = await c.req.raw.clone().text() if (!rawBody.trim()) return {} @@ -554,14 +550,14 @@ async function readLogsnagInsightsPayload(c: Context): Promise { - if (retryCount >= LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES) +async function reserveGlobalStatsRetry(c: Context, retryCount: number, dateId?: string): Promise { + if (retryCount >= GLOBAL_STATS_BACKGROUND_MAX_RETRIES) return null const nextRetryCount = retryCount + 1 - const delaySeconds = LOGSNAG_INSIGHTS_RETRY_DELAY_SECONDS * nextRetryCount - const retryMessage = buildLogsnagInsightsRetryMessage(nextRetryCount, dateId) + const delaySeconds = GLOBAL_STATS_RETRY_DELAY_SECONDS * nextRetryCount + const retryMessage = buildGlobalStatsRetryMessage(nextRetryCount, dateId) const db = getPgClient(c) try { - const retryMsgId = await queueLogsnagInsightsMessage(db, retryMessage, delaySeconds) + const retryMsgId = await queueGlobalStatsMessage(db, retryMessage, delaySeconds) cloudlog({ requestId: c.get('requestId'), - message: 'Reserved logsnag insights dispatcher retry', + message: 'Reserved global stats dispatcher retry', retryCount: nextRetryCount, delaySeconds, retryMsgId, @@ -636,20 +632,20 @@ async function reserveLogsnagInsightsRetry(c: Context, retryCount: number, dateI } } -async function reserveLogsnagInsightsShardRetry(c: Context, shard: GlobalStatsShard, dateId: string, retryCount: number): Promise { - if (retryCount >= LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES) +async function reserveGlobalStatsShardRetry(c: Context, shard: GlobalStatsShard, dateId: string, retryCount: number): Promise { + if (retryCount >= GLOBAL_STATS_BACKGROUND_MAX_RETRIES) return null const nextRetryCount = retryCount + 1 - const delaySeconds = LOGSNAG_INSIGHTS_RETRY_DELAY_SECONDS * nextRetryCount - const retryMessage = buildLogsnagInsightsShardMessage(shard, dateId, nextRetryCount) + const delaySeconds = GLOBAL_STATS_RETRY_DELAY_SECONDS * nextRetryCount + const retryMessage = buildGlobalStatsShardMessage(shard, dateId, nextRetryCount) const db = getPgClient(c) try { - const retryMsgId = await queueLogsnagInsightsMessage(db, retryMessage, delaySeconds) + const retryMsgId = await queueGlobalStatsMessage(db, retryMessage, delaySeconds) cloudlog({ requestId: c.get('requestId'), - message: 'Reserved logsnag insights shard retry', + message: 'Reserved global stats shard retry', shard, retryCount: nextRetryCount, delaySeconds, @@ -663,24 +659,24 @@ async function reserveLogsnagInsightsShardRetry(c: Context, shard: GlobalStatsSh } } -async function cancelLogsnagInsightsRetry(c: Context, retryMsgId: number): Promise { +async function cancelGlobalStatsRetry(c: Context, retryMsgId: number): Promise { const db = getPgClient(c) try { await db.query('SELECT pgmq.delete($1, $2::bigint[])', [ - LOGSNAG_INSIGHTS_QUEUE_NAME, + GLOBAL_STATS_QUEUE_NAME, [retryMsgId], ]) cloudlog({ requestId: c.get('requestId'), - message: 'Cancelled reserved logsnag insights dispatcher retry', + message: 'Cancelled reserved global stats dispatcher retry', retryMsgId, }) } catch (cancelError) { cloudlogErr({ requestId: c.get('requestId'), - message: 'Failed to cancel reserved logsnag insights dispatcher retry', + message: 'Failed to cancel reserved global stats dispatcher retry', retryMsgId, error: cancelError, }) @@ -696,7 +692,7 @@ function getPaidPlanTotal(plans: PlanTotal) { } function getPlanConversionRates(plans: PlanTotal, payingCount: number): PlanConversionRates { - // Plan mix among paying orgs (not all orgs/users). Matches LogSnag insight cards. + // Plan mix among paying orgs (not all orgs/users). Matches the daily admin snapshot. return { solo: calculateConversionRate(plans.Solo, payingCount), maker: calculateConversionRate(plans.Maker, payingCount), @@ -1906,7 +1902,7 @@ async function releaseGlobalStatsNotificationDeliveryClaim(c: Context, db: Retur } } -async function shouldSkipCompletedLogsnagInsightsRetryDispatch(c: Context, dateId: string, retryCount: number): Promise { +async function shouldSkipCompletedGlobalStatsRetryDispatch(c: Context, dateId: string, retryCount: number): Promise { if (retryCount <= 0) return false @@ -1916,10 +1912,10 @@ async function shouldSkipCompletedLogsnagInsightsRetryDispatch(c: Context, dateI return false if (!completedShards.has('notifications')) { - const queued = await queueLogsnagInsightsShard(c, 'notifications', dateId) + const queued = await queueGlobalStatsShard(c, 'notifications', dateId) cloudlog({ requestId: c.get('requestId'), - message: 'Queued missing logsnag insights notification shard for completed retry', + message: 'Queued missing global stats notification shard for completed retry', dateId, retryCount, queued, @@ -1930,7 +1926,7 @@ async function shouldSkipCompletedLogsnagInsightsRetryDispatch(c: Context, dateI cloudlog({ requestId: c.get('requestId'), - message: 'Skipping completed logsnag insights retry dispatch', + message: 'Skipping completed global stats retry dispatch', dateId, retryCount, completedShards: Array.from(completedShards).sort((a, b) => a.localeCompare(b)), @@ -2033,17 +2029,17 @@ async function runGlobalStatsNotificationProviderStep( completedShards.add(sentMarker) } -function getLogsnagInsightsShardDelaySeconds(shard: GlobalStatsShard): number { - return shard === 'notifications' ? LOGSNAG_INSIGHTS_NOTIFICATION_DELAY_SECONDS : 0 +function getGlobalStatsShardDelaySeconds(shard: GlobalStatsShard): number { + return shard === 'notifications' ? GLOBAL_STATS_NOTIFICATION_DELAY_SECONDS : 0 } -async function queueLogsnagInsightsMessage( +async function queueGlobalStatsMessage( db: ReturnType, - message: ReturnType | ReturnType, + message: ReturnType | ReturnType, delaySeconds: number, ): Promise { const result = await db.query<{ msg_id: number | string }>('SELECT pgmq.send($1::text, $2::jsonb, $3::integer) AS msg_id', [ - LOGSNAG_INSIGHTS_QUEUE_NAME, + GLOBAL_STATS_QUEUE_NAME, JSON.stringify(message), delaySeconds, ]) @@ -2053,12 +2049,12 @@ async function queueLogsnagInsightsMessage( return msgId } -async function queueLogsnagInsightsShard(c: Context, shard: GlobalStatsShard, dateId: string): Promise<{ shard: GlobalStatsShard, msgId: number, delaySeconds: number }> { +async function queueGlobalStatsShard(c: Context, shard: GlobalStatsShard, dateId: string): Promise<{ shard: GlobalStatsShard, msgId: number, delaySeconds: number }> { const db = getPgClient(c) try { - const delaySeconds = getLogsnagInsightsShardDelaySeconds(shard) - const msgId = await queueLogsnagInsightsMessage(db, buildLogsnagInsightsShardMessage(shard, dateId), delaySeconds) + const delaySeconds = getGlobalStatsShardDelaySeconds(shard) + const msgId = await queueGlobalStatsMessage(db, buildGlobalStatsShardMessage(shard, dateId), delaySeconds) return { shard, msgId, delaySeconds } } finally { @@ -2066,7 +2062,7 @@ async function queueLogsnagInsightsShard(c: Context, shard: GlobalStatsShard, da } } -async function queueLogsnagInsightsShards( +async function queueGlobalStatsShards( c: Context, dateId: string, shards: readonly GlobalStatsShard[], @@ -2079,8 +2075,8 @@ async function queueLogsnagInsightsShards( try { for (const shard of shards) { - const delaySeconds = getLogsnagInsightsShardDelaySeconds(shard) - const msgId = await queueLogsnagInsightsMessage(db, buildLogsnagInsightsShardMessage(shard, dateId), delaySeconds) + const delaySeconds = getGlobalStatsShardDelaySeconds(shard) + const msgId = await queueGlobalStatsMessage(db, buildGlobalStatsShardMessage(shard, dateId), delaySeconds) queued.push({ shard, msgId, delaySeconds }) } } @@ -2091,7 +2087,7 @@ async function queueLogsnagInsightsShards( return queued } -async function queueMissingLogsnagInsightsShards( +async function queueMissingGlobalStatsShards( c: Context, dateId: string, completedShards: ReadonlySet, @@ -2099,19 +2095,19 @@ async function queueMissingLogsnagInsightsShards( staleShards: readonly GlobalStatsShard[] = [], ): Promise> { const shardsToQueue = getGlobalStatsRepairShardQueueCandidates(completedShards, staleShards, candidateShards) - return queueLogsnagInsightsShards(c, dateId, shardsToQueue) + return queueGlobalStatsShards(c, dateId, shardsToQueue) } -function getLogsnagInsightsShardQueueKey(shard: GlobalStatsShard, dateId: string): string { - return `${dateId}:${getLogsnagInsightsShardFunctionName(shard)}` +function getGlobalStatsShardQueueKey(shard: GlobalStatsShard, dateId: string): string { + return `${dateId}:${getGlobalStatsShardFunctionName(shard)}` } -async function readQueuedLogsnagInsightsShardKeys(c: Context, dateIds: readonly string[]): Promise> { +async function readQueuedGlobalStatsShardKeys(c: Context, dateIds: readonly string[]): Promise> { if (dateIds.length === 0) return new Set() const db = getPgClient(c) - const functionNames = GLOBAL_STATS_SHARDS.map(shard => getLogsnagInsightsShardFunctionName(shard)) + const functionNames = GLOBAL_STATS_SHARDS.map(shard => getGlobalStatsShardFunctionName(shard)) try { const result = await db.query<{ function_name: string | null, date_id: string | null }>( @@ -2237,7 +2233,7 @@ async function repairRecentMissingGlobalStatsSnapshots(c: Context, anchorDateId: const [repairRows, queuedShardKeys] = await Promise.all([ readGlobalStatsRepairRows(c, dateIds), - readQueuedLogsnagInsightsShardKeys(c, dateIds), + readQueuedGlobalStatsShardKeys(c, dateIds), ]) const missingDateIds = dateIds.filter(dateId => !repairRows.has(dateId)) await ensureGlobalStatsSnapshotRows(c, missingDateIds) @@ -2254,9 +2250,9 @@ async function repairRecentMissingGlobalStatsSnapshots(c: Context, anchorDateId: const completedShards = repairRow?.completedShards ?? new Set() const staleShards = repairRow ? getGlobalStatsStaleRepairShards(repairRow, buildStatsByDate.get(dateId) ?? getEmptyBuildShardStats()) : [] const shardsToQueue = getGlobalStatsRepairShardQueueCandidates(completedShards, staleShards) - .filter(shard => !queuedShardKeys.has(getLogsnagInsightsShardQueueKey(shard, dateId))) + .filter(shard => !queuedShardKeys.has(getGlobalStatsShardQueueKey(shard, dateId))) - const queued = await queueLogsnagInsightsShards(c, dateId, shardsToQueue) + const queued = await queueGlobalStatsShards(c, dateId, shardsToQueue) if (queued.length > 0) queuedByDate.push({ dateId, staleShards, queued }) } @@ -2272,7 +2268,7 @@ async function repairRecentMissingGlobalStatsSnapshots(c: Context, anchorDateId: } } -async function dispatchMissingLogsnagInsightsShardsFor( +async function dispatchMissingGlobalStatsShardsFor( c: Context, dateId: string, candidateShards: readonly GlobalStatsShard[] | undefined, @@ -2286,7 +2282,7 @@ async function dispatchMissingLogsnagInsightsShardsFor( ? (await readDailyBuildStatsByDate(c, [dateId])).get(dateId) ?? getEmptyBuildShardStats() : getEmptyBuildShardStats() const staleShards = repairRow ? getGlobalStatsStaleRepairShards(repairRow, buildStats) : [] - const queued = await queueMissingLogsnagInsightsShards(c, dateId, completedShards, candidateShards, staleShards) + const queued = await queueMissingGlobalStatsShards(c, dateId, completedShards, candidateShards, staleShards) const completedShardNames = Array.from(completedShards).sort((a, b) => a.localeCompare(b)) if (queued.length === 0) { @@ -2310,34 +2306,34 @@ async function dispatchMissingLogsnagInsightsShardsFor( }) } -async function dispatchMissingLogsnagInsightsShards(c: Context, dateId: string): Promise { - await dispatchMissingLogsnagInsightsShardsFor( +async function dispatchMissingGlobalStatsShards(c: Context, dateId: string): Promise { + await dispatchMissingGlobalStatsShardsFor( c, dateId, undefined, - 'No missing logsnag insights global stats shards to queue', - 'Queued missing logsnag insights global stats shards', + 'No missing global stats shards to queue', + 'Queued missing global stats shards', ) } -async function dispatchMissingLogsnagInsightsUsageShards(c: Context, dateId: string): Promise { - await dispatchMissingLogsnagInsightsShardsFor( +async function dispatchMissingGlobalStatsUsageShards(c: Context, dateId: string): Promise { + await dispatchMissingGlobalStatsShardsFor( c, dateId, USAGE_GLOBAL_STATS_SHARDS, - 'No missing logsnag insights usage shards to queue', - 'Queued missing logsnag insights usage shards', + 'No missing global stats usage shards to queue', + 'Queued missing global stats usage shards', ) } -async function dispatchLogsnagInsightsShards(c: Context, dateId: string): Promise { +async function dispatchGlobalStatsShards(c: Context, dateId: string): Promise { await ensureGlobalStatsSnapshotRow(c, dateId) const completedShards = await readCompletedGlobalStatsShards(c, dateId) - const queued = await queueMissingLogsnagInsightsShards(c, dateId, completedShards) + const queued = await queueMissingGlobalStatsShards(c, dateId, completedShards) cloudlog({ requestId: c.get('requestId'), - message: 'Queued logsnag insights global stats shards', + message: 'Queued global stats shards', dateId, queued, completedShards: Array.from(completedShards).sort((a, b) => a.localeCompare(b)), @@ -3291,12 +3287,6 @@ function getNumber(value: number | null | undefined): number { return Number(value) || 0 } -function formatPercentCount(count: number, total: number): string { - if (total <= 0) - return `0% - ${count}` - return `${(count * 100 / total).toFixed(0)}% - ${count}` -} - interface NativeNotificationGlobalStats { apps: number providers: number @@ -3445,63 +3435,9 @@ async function runNotificationsGlobalStatsShard(c: Context, window: DailyWindow) }, undefined, { alert: false }) } - const paying = getNumber(snapshot.paying) const bundle_storage_gb = getNumber(snapshot.bundle_storage_gb) const success_rate = getNumber(snapshot.success_rate) const org_conversion_rate = getNumber(snapshot.org_conversion_rate) - const plans = normalizePlanTotals({ - Credits: getNumber(snapshot.plan_credits), - Enterprise: getNumber(snapshot.plan_enterprise), - Maker: getNumber(snapshot.plan_maker), - Solo: getNumber(snapshot.plan_solo), - Team: getNumber(snapshot.plan_team), - Trial: getNumber(snapshot.trial), - }) - - await runGlobalStatsNotificationProviderStep( - c, - window.prevDayDateId, - 'logsnag', - completedShards, - GLOBAL_STATS_NOTIFICATION_LOGSNAG_STEP, - GLOBAL_STATS_NOTIFICATION_LOGSNAG_CLAIM, - async () => { - await logsnagInsights(c, [ - { title: 'Apps', value: apps, icon: '๐Ÿ“ฑ' }, - { title: 'Active Apps', value: getNumber(snapshot.apps_active), icon: '๐Ÿ’ƒ' }, - { title: 'Updates', value: getNumber(snapshot.updates), icon: '๐Ÿ“ฒ' }, - { title: 'Updates on premises', value: getNumber(snapshot.updates_external), icon: '๐Ÿ“ฒ' }, - { title: 'Updates last month', value: getNumber(snapshot.updates_last_month), icon: '๐Ÿ“ฒ' }, - { title: 'Bundle Storage (GB)', value: `${bundle_storage_gb.toFixed(2)} GB`, icon: '๐Ÿ’พ' }, - { title: 'Total Users', value: users, icon: '๐Ÿ‘จ' }, - { title: 'Active Users', value: getNumber(snapshot.users_active), icon: '๐ŸŽ‰' }, - { title: 'Registrations Today', value: getNumber(snapshot.registers_today), icon: '๐Ÿ†•' }, - { title: 'User onboarded', value: getNumber(snapshot.onboarded), icon: 'โœ…' }, - { title: 'Orgs', value: orgs, icon: '๐Ÿข' }, - { title: 'Orgs with trial', value: plans.Trial, icon: '๐Ÿ‘ถ' }, - { title: 'Orgs paying', value: paying, icon: '๐Ÿ’ฐ' }, - { title: 'Org conversion rate', value: `${org_conversion_rate.toFixed(1)}%`, icon: '๐ŸŽฏ' }, - { title: 'Orgs yearly', value: formatPercentCount(getNumber(snapshot.paying_yearly), paying), icon: '๐Ÿงง' }, - { title: 'Orgs monthly', value: formatPercentCount(getNumber(snapshot.paying_monthly), paying), icon: '๐Ÿ—“๏ธ' }, - { title: 'Orgs not paying', value: getNumber(snapshot.not_paying), icon: '๐Ÿฅฒ' }, - { title: 'Orgs need upgrade', value: getNumber(snapshot.need_upgrade), icon: '๐Ÿค’' }, - { title: 'Orgs Solo Plan', value: formatPercentCount(plans.Solo, paying), icon: '๐ŸŽธ' }, - { title: 'Orgs Maker Plan', value: formatPercentCount(plans.Maker, paying), icon: '๐Ÿค' }, - { title: 'Orgs Team Plan', value: formatPercentCount(plans.Team, paying), icon: '๐Ÿ‘' }, - { title: 'Orgs Enterprise Plan', value: formatPercentCount(plans.Enterprise, paying), icon: '๐Ÿ“ˆ' }, - { title: 'Orgs Credits Plan', value: plans.Credits, icon: '๐Ÿช™' }, - { title: 'Devices iOS (30d)', value: getNumber(snapshot.devices_last_month_ios), icon: '๐ŸŽ' }, - { title: 'Devices Android (30d)', value: getNumber(snapshot.devices_last_month_android), icon: '๐Ÿค–' }, - { title: 'Total Builds', value: getNumber(snapshot.builds_total), icon: '๐Ÿ”จ' }, - { title: 'iOS Builds', value: getNumber(snapshot.builds_ios), icon: '๐Ÿ' }, - { title: 'Android Builds', value: getNumber(snapshot.builds_android), icon: '๐Ÿค–' }, - { title: 'Builds (30d)', value: getNumber(snapshot.builds_last_month), icon: '๐Ÿ”จ' }, - { title: 'iOS Builds (30d)', value: getNumber(snapshot.builds_last_month_ios), icon: '๐Ÿ' }, - { title: 'Android Builds (30d)', value: getNumber(snapshot.builds_last_month_android), icon: '๐Ÿค–' }, - ], { strict: true }) - }, - ) - await runGlobalStatsNotificationProviderStep( c, window.prevDayDateId, @@ -3521,27 +3457,26 @@ async function runNotificationsGlobalStatsShard(c: Context, window: DailyWindow) storage_gb: bundle_storage_gb, org_conversion_rate, }, - icon: '๐Ÿ“ฒ', }, { background: false, strict: true }) }, ) await markGlobalStatsShardComplete(c, window.prevDayDateId, 'notifications') - cloudlog({ requestId: c.get('requestId'), message: 'Sent logsnag insights from global stats snapshot', dateId: window.prevDayDateId }) + cloudlog({ requestId: c.get('requestId'), message: 'Sent global stats tracking event from snapshot', dateId: window.prevDayDateId }) } finally { await releaseGlobalStatsNotificationDeliveryClaim(c, notificationClaim, window.prevDayDateId) } } -function scheduleLogsnagInsightsUpdate( +function scheduleGlobalStatsUpdate( c: Context, - runUpdate: (c: Context) => Promise = runLogsnagInsightsUpdate, - options: ScheduleLogsnagInsightsUpdateOptions = {}, + runUpdate: (c: Context) => Promise = runGlobalStatsUpdate, + options: ScheduleGlobalStatsUpdateOptions = {}, ) { const retryCount = options.retryCount ?? 0 const retryMsgId = options.retryMsgId ?? null - const cancelRetry = options.cancelRetry ?? cancelLogsnagInsightsRetry + const cancelRetry = options.cancelRetry ?? cancelGlobalStatsRetry let updateSucceeded = false const task = Promise.resolve() .then(() => runUpdate(c)) @@ -3552,29 +3487,30 @@ function scheduleLogsnagInsightsUpdate( await cancelRetry(c, retryMsgId) }) .catch(async (error: unknown) => { - cloudlogErr({ requestId: c.get('requestId'), message: 'logsnag insights background task failed', retryCount, retryMsgId, updateSucceeded, error }) + cloudlogErr({ requestId: c.get('requestId'), message: 'global stats background task failed', retryCount, retryMsgId, updateSucceeded, error }) if (retryMsgId !== null && !updateSucceeded) return if (retryMsgId !== null) throw error - if (retryCount >= LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES) { - cloudlogErr({ requestId: c.get('requestId'), message: 'logsnag insights background retry budget exhausted', retryCount, error }) + if (retryCount >= GLOBAL_STATS_BACKGROUND_MAX_RETRIES) { + cloudlogErr({ requestId: c.get('requestId'), message: 'global stats background retry budget exhausted', retryCount, error }) throw error } }) - if (retryMsgId === null && retryCount >= LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES) + if (retryMsgId === null && retryCount >= GLOBAL_STATS_BACKGROUND_MAX_RETRIES) return task return backgroundTask(c, task) } -export const logsnagInsightsTestUtils = { - buildLogsnagInsightsRetryMessage, - buildLogsnagInsightsShardMessage, - readLogsnagInsightsPayload, +export const globalStatsTestUtils = { + buildGlobalStatsRetryMessage, + buildGlobalStatsShardMessage, + readGlobalStatsPayload, REVENUE_ACTIVE_STRIPE_STATUSES, - LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES, + GLOBAL_STATS_BACKGROUND_MAX_RETRIES, + GLOBAL_STATS_NOTIFICATION_LOCK_NAMESPACE, USAGE_GLOBAL_STATS_SHARDS, calculatePastDueOrgStats, calculateSubscriptionAccessSnapshotCounts, @@ -3606,13 +3542,13 @@ export const logsnagInsightsTestUtils = { summarizeAppBuildOnboardingRows, getGlobalStatsNotificationStepAction, normalizeCompletedGlobalStatsShards, - getLogsnagInsightsShardFunctionName, + getGlobalStatsShardFunctionName, getCompletedDayWindow, getCurrentDayWindow, getPreviousDateId, normalizeGlobalStatsDateId, - normalizeLogsnagInsightsShard, - normalizeLogsnagInsightsRetryCount, + normalizeGlobalStatsShard, + normalizeGlobalStatsRetryCount, normalizePlanTotals, normalizeBillingSnapshotCounts, isUnpaidAtBillingSnapshot, @@ -3623,20 +3559,20 @@ export const logsnagInsightsTestUtils = { normalizeCoreSnapshotCounts, getBillingSnapshotCounts, getCoreSnapshotCounts, - reserveLogsnagInsightsRetry, - reserveLogsnagInsightsShardRetry, - scheduleLogsnagInsightsUpdate, - scheduleLogsnagInsightsShardUpdate, + reserveGlobalStatsRetry, + reserveGlobalStatsShardRetry, + scheduleGlobalStatsUpdate, + scheduleGlobalStatsShardUpdate, } export const app = new Hono() -async function runLogsnagInsightsShard(c: Context, shard: GlobalStatsShard, dateId: string): Promise { +async function runGlobalStatsShard(c: Context, shard: GlobalStatsShard, dateId: string): Promise { const completedShards = await readCompletedGlobalStatsShards(c, dateId) if (await shouldSkipGlobalStatsShardUpdate(c, dateId, completedShards, shard)) { cloudlog({ requestId: c.get('requestId'), - message: 'Skipping completed logsnag insights shard retry', + message: 'Skipping completed global stats shard retry', shard, dateId, }) @@ -3711,16 +3647,16 @@ async function runLogsnagInsightsShard(c: Context, shard: GlobalStatsShard, date } } -function scheduleLogsnagInsightsShardUpdate( +function scheduleGlobalStatsShardUpdate( c: Context, shard: GlobalStatsShard, dateId: string, - options: ScheduleLogsnagInsightsShardOptions = {}, + options: ScheduleGlobalStatsShardOptions = {}, ) { const retryCount = options.retryCount ?? 0 const retryMsgId = options.retryMsgId ?? null - const cancelRetry = options.cancelRetry ?? cancelLogsnagInsightsRetry - const runShard = options.runShard ?? runLogsnagInsightsShard + const cancelRetry = options.cancelRetry ?? cancelGlobalStatsRetry + const runShard = options.runShard ?? runGlobalStatsShard let updateSucceeded = false const task = Promise.resolve() .then(() => runShard(c, shard, dateId)) @@ -3731,37 +3667,37 @@ function scheduleLogsnagInsightsShardUpdate( await cancelRetry(c, retryMsgId) }) .catch(async (error: unknown) => { - cloudlogErr({ requestId: c.get('requestId'), message: 'logsnag insights shard background task failed', shard, dateId, retryCount, retryMsgId, updateSucceeded, error }) + cloudlogErr({ requestId: c.get('requestId'), message: 'global stats shard background task failed', shard, dateId, retryCount, retryMsgId, updateSucceeded, error }) if (retryMsgId !== null && !updateSucceeded) return if (retryMsgId !== null) throw error - if (retryCount >= LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES) { - cloudlogErr({ requestId: c.get('requestId'), message: 'logsnag insights shard background retry budget exhausted', shard, dateId, retryCount, error }) + if (retryCount >= GLOBAL_STATS_BACKGROUND_MAX_RETRIES) { + cloudlogErr({ requestId: c.get('requestId'), message: 'global stats shard background retry budget exhausted', shard, dateId, retryCount, error }) throw error } }) - if (retryMsgId === null && retryCount >= LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES) + if (retryMsgId === null && retryCount >= GLOBAL_STATS_BACKGROUND_MAX_RETRIES) return task return backgroundTask(c, task) } -async function runLogsnagInsightsUpdate(c: Context, dateId = getDailyWindow().prevDayDateId, retryCount = 0): Promise { +async function runGlobalStatsUpdate(c: Context, dateId = getDailyWindow().prevDayDateId, retryCount = 0): Promise { await repairRecentMissingGlobalStatsSnapshots(c, dateId) - if (await shouldSkipCompletedLogsnagInsightsRetryDispatch(c, dateId, retryCount)) + if (await shouldSkipCompletedGlobalStatsRetryDispatch(c, dateId, retryCount)) return if (retryCount > 0) { - await dispatchMissingLogsnagInsightsShards(c, dateId) + await dispatchMissingGlobalStatsShards(c, dateId) return } - await dispatchLogsnagInsightsShards(c, dateId) + await dispatchGlobalStatsShards(c, dateId) } -function resolveLogsnagInsightsSnapshotDateId(payload: LogsnagInsightsPayload): string { +function resolveGlobalStatsSnapshotDateId(payload: GlobalStatsPayload): string { const payloadDateId = normalizeGlobalStatsDateId(payload.date_id) if (payload.date_id !== undefined && payloadDateId === null) quickError(400, 'invalid_global_stats_date_id', 'Invalid global stats date_id', { date_id: payload.date_id }, undefined, { alert: false }) @@ -3769,83 +3705,83 @@ function resolveLogsnagInsightsSnapshotDateId(payload: LogsnagInsightsPayload): return payloadDateId ?? getDailyWindow().prevDayDateId } -async function scheduleLogsnagInsightsShardRequest(c: Context, shard: GlobalStatsShard, snapshotDateId: string, retryCount: number): Promise { +async function scheduleGlobalStatsShardRequest(c: Context, shard: GlobalStatsShard, snapshotDateId: string, retryCount: number): Promise { let retryMsgId: number | null = null try { - retryMsgId = await reserveLogsnagInsightsShardRetry(c, shard, snapshotDateId, retryCount) + retryMsgId = await reserveGlobalStatsShardRetry(c, shard, snapshotDateId, retryCount) } catch (error) { - cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to reserve logsnag insights shard retry', shard, retryCount, dateId: snapshotDateId, error }) - quickError(503, 'logsnag_insights_shard_retry_reserve_failed', 'Failed to reserve logsnag insights shard retry', { shard, retryCount, dateId: snapshotDateId }, error, { alert: false }) + cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to reserve global stats shard retry', shard, retryCount, dateId: snapshotDateId, error }) + quickError(503, 'global_stats_shard_retry_reserve_failed', 'Failed to reserve global stats shard retry', { shard, retryCount, dateId: snapshotDateId }, error, { alert: false }) } - await scheduleLogsnagInsightsShardUpdate(c, shard, snapshotDateId, { + await scheduleGlobalStatsShardUpdate(c, shard, snapshotDateId, { retryCount, retryMsgId, }) } -function createLogsnagInsightsShardApp(shard: GlobalStatsShard): Hono { +function createGlobalStatsShardApp(shard: GlobalStatsShard): Hono { const shardApp = new Hono() shardApp.post('/', middlewareAPISecret, async (c) => { - const payload = await readLogsnagInsightsPayload(c) - const snapshotDateId = resolveLogsnagInsightsSnapshotDateId(payload) - const retryCount = normalizeLogsnagInsightsRetryCount(payload.retry_count) + const payload = await readGlobalStatsPayload(c) + const snapshotDateId = resolveGlobalStatsSnapshotDateId(payload) + const retryCount = normalizeGlobalStatsRetryCount(payload.retry_count) if (payload.shard !== undefined) { - const payloadShard = normalizeLogsnagInsightsShard(payload.shard) + const payloadShard = normalizeGlobalStatsShard(payload.shard) if (payloadShard !== shard) quickError(400, 'invalid_global_stats_shard', 'Invalid global stats shard', { shard: payload.shard, expected: shard }, undefined, { alert: false }) } - await scheduleLogsnagInsightsShardRequest(c, shard, snapshotDateId, retryCount) + await scheduleGlobalStatsShardRequest(c, shard, snapshotDateId, retryCount) return c.json(BRES, 202) }) return shardApp } -export const logsnagInsightsLegacyUsageApp = new Hono() +export const globalStatsLegacyUsageApp = new Hono() -logsnagInsightsLegacyUsageApp.post('/', middlewareAPISecret, async (c) => { - const payload = await readLogsnagInsightsPayload(c) - const snapshotDateId = resolveLogsnagInsightsSnapshotDateId(payload) - await dispatchMissingLogsnagInsightsUsageShards(c, snapshotDateId) +globalStatsLegacyUsageApp.post('/', middlewareAPISecret, async (c) => { + const payload = await readGlobalStatsPayload(c) + const snapshotDateId = resolveGlobalStatsSnapshotDateId(payload) + await dispatchMissingGlobalStatsUsageShards(c, snapshotDateId) return c.json(BRES, 202) }) -export const logsnagInsightsShardApps: Record> = { - core: createLogsnagInsightsShardApp('core'), - usage_updates: createLogsnagInsightsShardApp('usage_updates'), - usage_devices: createLogsnagInsightsShardApp('usage_devices'), - usage_device_platforms: createLogsnagInsightsShardApp('usage_device_platforms'), - usage_registrations: createLogsnagInsightsShardApp('usage_registrations'), - usage_storage: createLogsnagInsightsShardApp('usage_storage'), - usage_success_rate: createLogsnagInsightsShardApp('usage_success_rate'), - usage_demo_apps: createLogsnagInsightsShardApp('usage_demo_apps'), - revenue: createLogsnagInsightsShardApp('revenue'), - plugins: createLogsnagInsightsShardApp('plugins'), - builds: createLogsnagInsightsShardApp('builds'), - retention: createLogsnagInsightsShardApp('retention'), - paid_products: createLogsnagInsightsShardApp('paid_products'), - ltv: createLogsnagInsightsShardApp('ltv'), - notifications: createLogsnagInsightsShardApp('notifications'), - native_notifications: createLogsnagInsightsShardApp('native_notifications'), +export const globalStatsShardApps: Record> = { + core: createGlobalStatsShardApp('core'), + usage_updates: createGlobalStatsShardApp('usage_updates'), + usage_devices: createGlobalStatsShardApp('usage_devices'), + usage_device_platforms: createGlobalStatsShardApp('usage_device_platforms'), + usage_registrations: createGlobalStatsShardApp('usage_registrations'), + usage_storage: createGlobalStatsShardApp('usage_storage'), + usage_success_rate: createGlobalStatsShardApp('usage_success_rate'), + usage_demo_apps: createGlobalStatsShardApp('usage_demo_apps'), + revenue: createGlobalStatsShardApp('revenue'), + plugins: createGlobalStatsShardApp('plugins'), + builds: createGlobalStatsShardApp('builds'), + retention: createGlobalStatsShardApp('retention'), + paid_products: createGlobalStatsShardApp('paid_products'), + ltv: createGlobalStatsShardApp('ltv'), + notifications: createGlobalStatsShardApp('notifications'), + native_notifications: createGlobalStatsShardApp('native_notifications'), } app.post('/', middlewareAPISecret, async (c) => { - const payload = await readLogsnagInsightsPayload(c) - const snapshotDateId = resolveLogsnagInsightsSnapshotDateId(payload) - const retryCount = normalizeLogsnagInsightsRetryCount(payload.retry_count) + const payload = await readGlobalStatsPayload(c) + const snapshotDateId = resolveGlobalStatsSnapshotDateId(payload) + const retryCount = normalizeGlobalStatsRetryCount(payload.retry_count) if (payload.shard !== undefined) { - const shard = normalizeLogsnagInsightsShard(payload.shard) + const shard = normalizeGlobalStatsShard(payload.shard) if (shard === null) quickError(400, 'invalid_global_stats_shard', 'Invalid global stats shard', { shard: payload.shard }, undefined, { alert: false }) - await scheduleLogsnagInsightsShardRequest(c, shard, snapshotDateId, retryCount) + await scheduleGlobalStatsShardRequest(c, shard, snapshotDateId, retryCount) return c.json(BRES, 202) } @@ -3853,14 +3789,14 @@ app.post('/', middlewareAPISecret, async (c) => { try { // Reserve the next delayed dispatcher retry before returning 202 so queue_consumer can acknowledge the current message safely. - retryMsgId = await reserveLogsnagInsightsRetry(c, retryCount, snapshotDateId) + retryMsgId = await reserveGlobalStatsRetry(c, retryCount, snapshotDateId) } catch (error) { - cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to reserve logsnag insights dispatcher retry', retryCount, dateId: snapshotDateId, error }) - quickError(503, 'logsnag_insights_retry_reserve_failed', 'Failed to reserve logsnag insights retry', { retryCount, dateId: snapshotDateId }, error, { alert: false }) + cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to reserve global stats dispatcher retry', retryCount, dateId: snapshotDateId, error }) + quickError(503, 'global_stats_retry_reserve_failed', 'Failed to reserve global stats retry', { retryCount, dateId: snapshotDateId }, error, { alert: false }) } - await scheduleLogsnagInsightsUpdate(c, context => runLogsnagInsightsUpdate(context, snapshotDateId, retryCount), { + await scheduleGlobalStatsUpdate(c, context => runGlobalStatsUpdate(context, snapshotDateId, retryCount), { retryCount, retryMsgId, }) diff --git a/supabase/functions/_backend/triggers/on_app_create.ts b/supabase/functions/_backend/triggers/on_app_create.ts index a8b5b69611..360dd30933 100644 --- a/supabase/functions/_backend/triggers/on_app_create.ts +++ b/supabase/functions/_backend/triggers/on_app_create.ts @@ -138,7 +138,6 @@ app.post('/', middlewareAPISecret, triggerValidator('apps', 'INSERT'), async (c) bento: appCreatedBentoEvent, channel: 'app-created', event: isDemo ? 'Demo App Created' : isPendingOnboarding ? 'Onboarding App Created' : 'App Created', - icon: isDemo ? '๐ŸŽฎ' : isPendingOnboarding ? '๐Ÿงญ' : '๐ŸŽ‰', sentToBento: Boolean(appCreatedBentoEvent), user_id: ownerOrg, groups: { organization: ownerOrg }, @@ -147,7 +146,6 @@ app.post('/', middlewareAPISecret, triggerValidator('apps', 'INSERT'), async (c) is_demo: isDemo ? 'true' : 'false', need_onboarding: isPendingOnboarding ? 'true' : 'false', }, - notify: false, }) // Purge on-prem cache for this app to clear any stale responses diff --git a/supabase/functions/_backend/triggers/on_deploy_history_create.ts b/supabase/functions/_backend/triggers/on_deploy_history_create.ts index 8e85d1d745..a077e628d6 100644 --- a/supabase/functions/_backend/triggers/on_deploy_history_create.ts +++ b/supabase/functions/_backend/triggers/on_deploy_history_create.ts @@ -56,7 +56,6 @@ app.post('/', middlewareAPISecret, triggerValidator('deploy_history', 'INSERT'), await sendEventToTracking(c, { channel: 'bundle-deployed', event: 'Bundle Deployed', - icon: '๐Ÿš€', user_id: version.owner_org, groups: { organization: version.owner_org }, tags: { @@ -64,7 +63,6 @@ app.post('/', middlewareAPISecret, triggerValidator('deploy_history', 'INSERT'), bundle_name: version.name, channel_id: record.channel_id, }, - notify: false, }) await backgroundTask(c, (async () => { diff --git a/supabase/functions/_backend/triggers/on_organization_create.ts b/supabase/functions/_backend/triggers/on_organization_create.ts index 5477459757..21ba5afc35 100644 --- a/supabase/functions/_backend/triggers/on_organization_create.ts +++ b/supabase/functions/_backend/triggers/on_organization_create.ts @@ -79,11 +79,9 @@ app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) }, channel: 'org-created', event: 'Org Created', - icon: '๐ŸŽ‰', sentToBento: true, user_id: record.id, groups: { organization: record.id }, - notify: false, }) return c.json(BRES) diff --git a/supabase/functions/_backend/triggers/on_user_create.ts b/supabase/functions/_backend/triggers/on_user_create.ts index 09a21b5e50..d342a8c244 100644 --- a/supabase/functions/_backend/triggers/on_user_create.ts +++ b/supabase/functions/_backend/triggers/on_user_create.ts @@ -53,13 +53,11 @@ app.post('/', middlewareAPISecret, triggerValidator('users', 'INSERT'), async (c await sendEventToTracking(c, { channel: 'user-register', event: !record.created_via_invite ? 'User Joined' : 'User Joined by Invite', - icon: '๐ŸŽ‰', user_id: record.id, - notify: false, }).catch((error) => { cloudlog({ requestId: c.get('requestId'), - message: 'LogSnag.track user-register failed', + message: 'User registration tracking failed', error, }) }) diff --git a/supabase/functions/_backend/triggers/on_version_create.ts b/supabase/functions/_backend/triggers/on_version_create.ts index 21f91c90d7..8b8faacf60 100644 --- a/supabase/functions/_backend/triggers/on_version_create.ts +++ b/supabase/functions/_backend/triggers/on_version_create.ts @@ -45,14 +45,12 @@ app.post('/', middlewareAPISecret, triggerValidator('app_versions', 'INSERT'), a await sendEventToTracking(c, { channel: 'bundle-created', event: 'Bundle Created', - icon: '๐ŸŽ‰', user_id: record.owner_org, groups: { organization: record.owner_org }, tags: { app_id: record.app_id, bundle_name: record.name, }, - notify: false, }) const pgClient = getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) diff --git a/supabase/functions/_backend/triggers/stripe_event.ts b/supabase/functions/_backend/triggers/stripe_event.ts index 63a5d29502..93a400258f 100644 --- a/supabase/functions/_backend/triggers/stripe_event.ts +++ b/supabase/functions/_backend/triggers/stripe_event.ts @@ -987,11 +987,9 @@ async function customerSourceCreated(c: Context, org: Org, stripeEvent: Stripe.C }, channel: 'usage', event: 'Credit Card Added', - icon: '๐Ÿ’ณ', sentToBento: true, user_id: org.id, groups: { organization: org.id }, - notify: false, }) return c.json(BRES) } @@ -1008,11 +1006,9 @@ async function customerSourceExpiring(c: Context, org: Org) { }, channel: 'usage', event: 'Credit Card Expiring', - icon: 'โš ๏ธ', sentToBento: true, user_id: org.id, groups: { organization: org.id }, - notify: false, }) return c.json(BRES) } @@ -1047,11 +1043,9 @@ async function invoiceUpcoming(c: Context, org: Org, stripeEvent: Stripe.Invoice }, channel: 'usage', event: 'Invoice Upcoming', - icon: '๐Ÿ“„', sentToBento: true, user_id: org.id, groups: { organization: org.id }, - notify: false, }) return c.json(BRES) } @@ -1157,11 +1151,9 @@ async function createdOrUpdated( }, channel: 'usage', event: planChangeEventName, - icon: '๐Ÿ’ฐ', sentToBento: true, user_id: org.id, groups: { organization: org.id }, - notify: true, tags: planChangeMetadata, }) } @@ -1187,11 +1179,9 @@ async function createdOrUpdated( }, channel: 'usage', event: isNewSubscription ? 'User subscribe' : 'User update subscribe', - icon: '๐Ÿ’ฐ', sentToBento: true, user_id: org.id, groups: { organization: org.id }, - notify: isNewSubscription, tags: subscriptionMetadata, }) @@ -1260,11 +1250,9 @@ async function didCancel(c: Context, org: Org, customerId: string) { }, channel: 'usage', event: 'User cancel', - icon: 'โš ๏ธ', sentToBento: true, user_id: org.id, groups: { organization: org.id }, - notify: true, }) await backgroundTask(c, groupIdentifyPosthog(c, { diff --git a/supabase/functions/_backend/utils/build_tracking.ts b/supabase/functions/_backend/utils/build_tracking.ts index dcc0ecc1f0..0d199b5d6a 100644 --- a/supabase/functions/_backend/utils/build_tracking.ts +++ b/supabase/functions/_backend/utils/build_tracking.ts @@ -84,13 +84,6 @@ const EVENT_NAME_BY_TRANSITION: Record = { timed_out: 'Build Timed Out', } -const ICON_BY_TRANSITION: Record = { - started: 'โณ', - succeeded: 'โœ…', - failed: 'โŒ', - timed_out: 'โฐ', -} - /** * Emit the appropriate Build * lifecycle event for a status transition, or no-op when * `classifyBuildTransition` returns null (already-terminal previous status, or no change). @@ -138,8 +131,6 @@ export async function emitBuildTransitionEvent(c: Context, input: EmitBuildTrans await sendEventToTracking(c, { event: EVENT_NAME_BY_TRANSITION[transition], channel: 'build-lifecycle', - icon: ICON_BY_TRANSITION[transition], - notify: false, user_id: input.build.requested_by, groups: { organization: input.build.owner_org }, tags, diff --git a/supabase/functions/_backend/utils/logsnag.ts b/supabase/functions/_backend/utils/logsnag.ts deleted file mode 100644 index d711665451..0000000000 --- a/supabase/functions/_backend/utils/logsnag.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Context } from 'hono' -import { LogSnag } from '@logsnag/node' - -import { cloudlog, cloudlogErr, serializeError } from './logging.ts' -import { getEnv } from './utils.ts' - -function logsnag(c: Context) { - const ls = getEnv(c, 'LOGSNAG_TOKEN') - ? new LogSnag({ - token: getEnv(c, 'LOGSNAG_TOKEN'), - project: getEnv(c, 'LOGSNAG_PROJECT'), - }) - : { - publish: () => Promise.resolve(true), - track: (_obj: any) => Promise.resolve(true), - insight: { - track: (_obj: any) => Promise.resolve(true), - increment: () => Promise.resolve(true), - }, - } - return ls as LogSnag -} - -interface LogsnagInsightsOptions { - strict?: boolean -} - -async function logsnagInsights(c: Context, data: { title: string, value: string | boolean | number, icon: string }[], options: LogsnagInsightsOptions = {}) { - cloudlog({ requestId: c.get('requestId'), message: 'logsnagInsights', data }) - const ls = getEnv(c, 'LOGSNAG_TOKEN') - const project = getEnv(c, 'LOGSNAG_PROJECT') - if (!ls || !project) { - const error = new Error('LogSnag insights is not configured') - cloudlogErr({ requestId: c.get('requestId'), message: 'logsnagInsights error', error: serializeError(error) }) - if (options.strict) - throw error - return Promise.resolve(false) - } - - // Send all insights in parallel - const promises = data.map(async (d) => { - const payload = { - title: d.title, - value: d.value, - icon: d.icon, - project, - } - - try { - const response = await fetch('https://api.logsnag.com/v1/insight', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${ls}`, - }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - const error = await response.text() - cloudlogErr({ requestId: c.get('requestId'), message: 'logsnagInsights error', status: response.status, error, payload }) - if (options.strict) - throw new Error(`LogSnag insight failed with HTTP ${response.status}`) - return false - } - - return await response.json() - } - catch (e) { - cloudlogErr({ requestId: c.get('requestId'), message: 'logsnagInsights error', error: serializeError(e), payload }) - if (options.strict) - throw e - return false - } - }) - - return Promise.all(promises) -} - -export { logsnag, logsnagInsights } diff --git a/supabase/functions/_backend/utils/plans.ts b/supabase/functions/_backend/utils/plans.ts index 207a561e86..6deccd6efd 100644 --- a/supabase/functions/_backend/utils/plans.ts +++ b/supabase/functions/_backend/utils/plans.ts @@ -428,10 +428,8 @@ async function userAbovePlan(c: Context, org: { await sendEventToTracking(c, { channel: 'usage', event: `User need upgrade to ${bestPlanKey}`, - icon: 'โš ๏ธ', user_id: orgId, groups: { organization: orgId }, - notify: false, }).catch() } @@ -459,10 +457,8 @@ async function userIsAtPlanUsage(c: Context, orgId: string, customerId: string | await sendEventToTracking(c, { channel: 'usage', event: `User is at ${alert.threshold}% of plan usage`, - icon: 'โš ๏ธ', user_id: orgId, groups: { organization: orgId }, - notify: false, tags: { metric: alert.metric, metric_percent: alert.metricPercent.toString(), @@ -558,10 +554,8 @@ export async function handleOrgNotificationsAndEvents(c: Context, org: any, orgI await sendEventToTracking(c, { channel: 'usage', event: 'User need onboarding', - icon: '๐Ÿฅฒ', user_id: orgId, groups: { organization: orgId }, - notify: false, }).catch() } } diff --git a/supabase/functions/_backend/utils/posthog.ts b/supabase/functions/_backend/utils/posthog.ts index 1d28754894..46ffb9212e 100644 --- a/supabase/functions/_backend/utils/posthog.ts +++ b/supabase/functions/_backend/utils/posthog.ts @@ -1,4 +1,3 @@ -import type { TrackOptions } from '@logsnag/node' import type { Context } from 'hono' import { cloudlog, cloudlogErr, serializeError } from './logging.ts' import { existInEnv, getEnv, trimTrailingSlashes } from './utils.ts' @@ -12,8 +11,11 @@ const RRWEB_META_EVENT_TYPE = 4 export type PostHogGroups = Record -interface PostHogCapturePayload extends Pick, Pick { +interface PostHogCapturePayload { + channel: string + description?: string distinct_id?: string + event: string groups?: PostHogGroups ip?: string personProperties?: Record diff --git a/supabase/functions/_backend/utils/tracking.ts b/supabase/functions/_backend/utils/tracking.ts index 8409ac0fc0..c3efd6e928 100644 --- a/supabase/functions/_backend/utils/tracking.ts +++ b/supabase/functions/_backend/utils/tracking.ts @@ -1,14 +1,21 @@ -import type { TrackOptions } from '@logsnag/node' import type { Context } from 'hono' import type { EmailPreferenceKey, NotificationAudience } from './org_email_notifications.ts' import type { PostHogGroups } from './posthog.ts' import { cloudlogErr, serializeError } from './logging.ts' -import { logsnag } from './logsnag.ts' import { sendNotifToOrgMembers, sendNotifToOrgMembersOnce } from './org_email_notifications.ts' import { getDrizzleClient, getPgClient } from './pg.ts' import { trackPosthogEvent } from './posthog.ts' import { backgroundTask } from './utils.ts' +export interface TrackOptions { + channel: string + event: string + description?: string + user_id?: string + tags?: Record + timestamp?: number | Date +} + export interface BentoTrackingPayload { /** Cron window for the throttle/dedupe. Used only when `once` is not set. */ cron?: string @@ -90,24 +97,32 @@ function getTrackingIp(c: Context, ip?: string) { return c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for')?.split(',')[0]?.trim() } +function getTrackingTimestamp(timestamp?: number | Date) { + if (timestamp === undefined) + return undefined + + const date = timestamp instanceof Date ? timestamp : new Date(timestamp) + if (!Number.isFinite(date.getTime())) + return undefined + + return date.toISOString() +} + async function executeTracking(c: Context, payload: SendEventToTrackingPayload, options: SendEventToTrackingOptions) { - const tasks: Array> = [ - runTrackedCall(c, 'logsnag', () => logsnag(c).track(payload), options.strict), - ] - if (options.posthog !== false) { - tasks.push(runTrackedCall(c, 'posthog', () => trackPosthogEvent(c, { - event: payload.event, - user_id: payload.user_id, - tags: payload.tags, - nonPersonTags: payload.nonPersonTags, - channel: payload.channel, - description: payload.description, - groups: payload.groups, - ip: getTrackingIp(c, options.ip), - }), options.strict)) - } + if (options.posthog === false) + return - await Promise.all(tasks) + await runTrackedCall(c, 'posthog', () => trackPosthogEvent(c, { + event: payload.event, + user_id: payload.user_id, + tags: payload.tags, + nonPersonTags: payload.nonPersonTags, + channel: payload.channel, + description: payload.description, + groups: payload.groups, + ip: getTrackingIp(c, options.ip), + timestamp: getTrackingTimestamp(payload.timestamp), + }), options.strict) } async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayload, strict = false) { diff --git a/supabase/functions/deno.json b/supabase/functions/deno.json index 6f985522b3..fad939c833 100644 --- a/supabase/functions/deno.json +++ b/supabase/functions/deno.json @@ -7,7 +7,6 @@ "hono": "npm:hono@4.12.23", "hono/": "npm:/hono@4.12.23/", "stripe": "npm:stripe@22.1.1", - "@logsnag/node": "npm:@logsnag/node@1.0.1", "cron-schedule": "npm:cron-schedule@6.0.0", "dayjs": "npm:dayjs@1.11.21", "dayjs/": "npm:/dayjs@1.11.21/", diff --git a/supabase/functions/deno.lock b/supabase/functions/deno.lock index fb1c242625..80e924273c 100644 --- a/supabase/functions/deno.lock +++ b/supabase/functions/deno.lock @@ -5,7 +5,6 @@ "jsr:@std/semver@1.0.8": "1.0.8", "npm:@cloudflare/workers-types@4.20260526.1": "4.20260526.1", "npm:@hono/standard-validator@~0.2.2": "0.2.2_@standard-schema+spec@1.1.0_hono@4.12.23", - "npm:@logsnag/node@1.0.1": "1.0.1", "npm:@standard-schema/spec@^1.1.0": "1.1.0", "npm:@supabase/supabase-js@2.106.2": "2.106.2", "npm:@types/pg@*": "8.20.0", @@ -39,9 +38,6 @@ "hono" ] }, - "@logsnag/node@1.0.1": { - "integrity": "sha512-JW2S1KN91XyOb0oG2PblboZ1Ys4mkOSMn83GDYjM8CXzcFbkYFMnlFQoEgP0Y5z+1A56hOO+a7uLsvxO5IdUFA==" - }, "@standard-schema/spec@1.1.0": { "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, @@ -224,7 +220,6 @@ "jsr:@std/semver@1.0.8", "npm:@cloudflare/workers-types@4.20260526.1", "npm:@hono/standard-validator@~0.2.2", - "npm:@logsnag/node@1.0.1", "npm:@standard-schema/spec@^1.1.0", "npm:@supabase/supabase-js@2.106.2", "npm:@types/pg@8.20.0", diff --git a/supabase/functions/triggers/index.ts b/supabase/functions/triggers/index.ts index 83b7d48728..4f95015d2d 100644 --- a/supabase/functions/triggers/index.ts +++ b/supabase/functions/triggers/index.ts @@ -9,7 +9,7 @@ import { app as cron_rollout_auto_pause } from '../_backend/triggers/cron_rollou import { app as cron_stat_app } from '../_backend/triggers/cron_stat_app.ts' import { app as cron_stat_org } from '../_backend/triggers/cron_stat_org.ts' import { app as cron_sync_sub } from '../_backend/triggers/cron_sync_sub.ts' -import { app as logsnag_insights, logsnagInsightsLegacyUsageApp, logsnagInsightsShardApps } from '../_backend/triggers/logsnag_insights.ts' +import { app as global_stats, globalStatsLegacyUsageApp, globalStatsShardApps } from '../_backend/triggers/global_stats.ts' import { app as on_app_create } from '../_backend/triggers/on_app_create.ts' import { app as on_app_delete } from '../_backend/triggers/on_app_delete.ts' import { app as on_app_update } from '../_backend/triggers/on_app_update.ts' @@ -38,24 +38,24 @@ const functionName = 'triggers' const appGlobal = createHono(functionName, version) appGlobal.route('/cron_email', cron_email) -appGlobal.route('/logsnag_insights', logsnag_insights) -appGlobal.route('/logsnag_insights_core', logsnagInsightsShardApps.core) -appGlobal.route('/logsnag_insights_usage', logsnagInsightsLegacyUsageApp) -appGlobal.route('/logsnag_insights_usage_updates', logsnagInsightsShardApps.usage_updates) -appGlobal.route('/logsnag_insights_usage_devices', logsnagInsightsShardApps.usage_devices) -appGlobal.route('/logsnag_insights_usage_device_platforms', logsnagInsightsShardApps.usage_device_platforms) -appGlobal.route('/logsnag_insights_usage_registrations', logsnagInsightsShardApps.usage_registrations) -appGlobal.route('/logsnag_insights_usage_storage', logsnagInsightsShardApps.usage_storage) -appGlobal.route('/logsnag_insights_usage_success_rate', logsnagInsightsShardApps.usage_success_rate) -appGlobal.route('/logsnag_insights_usage_demo_apps', logsnagInsightsShardApps.usage_demo_apps) -appGlobal.route('/logsnag_insights_revenue', logsnagInsightsShardApps.revenue) -appGlobal.route('/logsnag_insights_plugins', logsnagInsightsShardApps.plugins) -appGlobal.route('/logsnag_insights_builds', logsnagInsightsShardApps.builds) -appGlobal.route('/logsnag_insights_retention', logsnagInsightsShardApps.retention) -appGlobal.route('/logsnag_insights_paid_products', logsnagInsightsShardApps.paid_products) -appGlobal.route('/logsnag_insights_ltv', logsnagInsightsShardApps.ltv) -appGlobal.route('/logsnag_insights_notifications', logsnagInsightsShardApps.notifications) -appGlobal.route('/logsnag_insights_native_notifications', logsnagInsightsShardApps.native_notifications) +appGlobal.route('/global_stats', global_stats) +appGlobal.route('/global_stats_core', globalStatsShardApps.core) +appGlobal.route('/global_stats_usage', globalStatsLegacyUsageApp) +appGlobal.route('/global_stats_usage_updates', globalStatsShardApps.usage_updates) +appGlobal.route('/global_stats_usage_devices', globalStatsShardApps.usage_devices) +appGlobal.route('/global_stats_usage_device_platforms', globalStatsShardApps.usage_device_platforms) +appGlobal.route('/global_stats_usage_registrations', globalStatsShardApps.usage_registrations) +appGlobal.route('/global_stats_usage_storage', globalStatsShardApps.usage_storage) +appGlobal.route('/global_stats_usage_success_rate', globalStatsShardApps.usage_success_rate) +appGlobal.route('/global_stats_usage_demo_apps', globalStatsShardApps.usage_demo_apps) +appGlobal.route('/global_stats_revenue', globalStatsShardApps.revenue) +appGlobal.route('/global_stats_plugins', globalStatsShardApps.plugins) +appGlobal.route('/global_stats_builds', globalStatsShardApps.builds) +appGlobal.route('/global_stats_retention', globalStatsShardApps.retention) +appGlobal.route('/global_stats_paid_products', globalStatsShardApps.paid_products) +appGlobal.route('/global_stats_ltv', globalStatsShardApps.ltv) +appGlobal.route('/global_stats_notifications', globalStatsShardApps.notifications) +appGlobal.route('/global_stats_native_notifications', globalStatsShardApps.native_notifications) appGlobal.route('/on_channel_update', on_channel_update) appGlobal.route('/on_user_create', on_user_create) appGlobal.route('/on_user_update', on_user_update) diff --git a/supabase/migrations/20260819075722_remove_legacy_tracking_provider.sql b/supabase/migrations/20260819075722_remove_legacy_tracking_provider.sql new file mode 100644 index 0000000000..d0caf242ba --- /dev/null +++ b/supabase/migrations/20260819075722_remove_legacy_tracking_provider.sql @@ -0,0 +1,46 @@ +CREATE OR REPLACE FUNCTION public.process_admin_stats() +RETURNS void +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + PERFORM pgmq.send( + 'admin_stats', + jsonb_build_object( + 'function_name', 'global_stats', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object() + ) + ); +END; +$$; + +ALTER FUNCTION public.process_admin_stats() OWNER TO postgres; +REVOKE ALL ON FUNCTION public.process_admin_stats() FROM public; + +-- Keep already queued admin-stat work compatible with the renamed HTTP routes. +-- The queue is bounded operational state; archived messages do not execute. +UPDATE pgmq.q_admin_stats +SET + message = jsonb_set( + message, + '{function_name}', + to_jsonb( + 'global_stats' + || substr( + message ->> 'function_name', length('logsnag_insights') + 1 + ) + ) + ) +WHERE + message ->> 'function_name' = 'logsnag_insights' + OR message ->> 'function_name' LIKE 'logsnag_insights\_%' ESCAPE '\'; + +UPDATE public.global_stats +SET + completed_shards = completed_shards + - 'notifications_logsnag' + - 'notifications_logsnag_claim' +WHERE + completed_shards ? 'notifications_logsnag' + OR completed_shards ? 'notifications_logsnag_claim'; diff --git a/tests/admin-stats.test.ts b/tests/admin-stats.test.ts index 28ee5c91d2..fea6df2526 100644 --- a/tests/admin-stats.test.ts +++ b/tests/admin-stats.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import process from 'node:process' import { Hono } from 'hono/tiny' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { logsnagInsightsTestUtils } from '../supabase/functions/_backend/triggers/logsnag_insights.ts' +import { globalStatsTestUtils } from '../supabase/functions/_backend/triggers/global_stats.ts' import { REQUIRED_GLOBAL_STATS_SHARDS } from '../supabase/functions/_backend/utils/global_stats.ts' import { getAdminGlobalStatsTrend, getAdminOnboardingFunnel } from '../supabase/functions/_backend/utils/pg.ts' import { BASE_URL, executeSQL, fetchTestRequest, getAuthHeadersForCredentials, getEndpointUrl, getSupabaseClient, POSTGRES_URL, PRODUCT_ID, resetAndSeedAppData, resetAppData, TEST_EMAIL, USER_ADMIN_EMAIL, USER_ID, USER_PASSWORD_HASH } from './test-utils.ts' @@ -106,7 +106,7 @@ async function getCoreSnapshotCountsAt(snapshotExclusiveEnd: Date) { abovePlanWithCredits: number abovePlanWithoutCredits: number }>(app => { - app.get('/', async c => c.json(await logsnagInsightsTestUtils.getCoreSnapshotCounts(c, snapshotExclusiveEnd))) + app.get('/', async c => c.json(await globalStatsTestUtils.getCoreSnapshotCounts(c, snapshotExclusiveEnd))) }) } @@ -114,7 +114,7 @@ async function getBillingSnapshotCountsAt(snapshotExclusiveEnd: Date) { return requestDirectAdminStats<{ plans: Record }>(app => { - app.get('/', async c => c.json(await logsnagInsightsTestUtils.getBillingSnapshotCounts(c, snapshotExclusiveEnd))) + app.get('/', async c => c.json(await globalStatsTestUtils.getBillingSnapshotCounts(c, snapshotExclusiveEnd))) }) } diff --git a/tests/ai-analysis-telemetry.unit.test.ts b/tests/ai-analysis-telemetry.unit.test.ts index 3881dbb420..bfe25ffece 100644 --- a/tests/ai-analysis-telemetry.unit.test.ts +++ b/tests/ai-analysis-telemetry.unit.test.ts @@ -35,8 +35,6 @@ describe('trackAiAnalysisChoice', () => { expect(payload).toMatchObject({ event: 'CLI AI Build Analysis Choice', channel: 'build-lifecycle', - icon: '๐Ÿค–', - notify: false, org_id: 'org-uuid-1', tracking_version: 2, tags: { @@ -87,8 +85,6 @@ describe('trackAiAnalysisResult', () => { expect(payload).toMatchObject({ event: 'CLI AI Build Analysis Result', channel: 'build-lifecycle', - icon: '๐Ÿค–', - notify: false, org_id: 'org-uuid-1', tracking_version: 2, tags: { diff --git a/tests/build-lifecycle-emit.unit.test.ts b/tests/build-lifecycle-emit.unit.test.ts index 9d0dac9890..1f09d2ea74 100644 --- a/tests/build-lifecycle-emit.unit.test.ts +++ b/tests/build-lifecycle-emit.unit.test.ts @@ -39,8 +39,6 @@ describe('emitBuildTransitionEvent', () => { expect(payload).toMatchObject({ event: 'Build Started', channel: 'build-lifecycle', - icon: 'โณ', - notify: false, user_id: 'user-uuid-1', groups: { organization: 'org-uuid-1' }, tags: { @@ -66,7 +64,6 @@ describe('emitBuildTransitionEvent', () => { const [, payload] = sendEventToTrackingMock.mock.calls[0] expect(payload).toMatchObject({ event: 'Build Succeeded', - icon: 'โœ…', tags: { duration_seconds: '123', }, @@ -87,7 +84,6 @@ describe('emitBuildTransitionEvent', () => { const [, payload] = sendEventToTrackingMock.mock.calls[0] expect(payload).toMatchObject({ event: 'Build Failed', - icon: 'โŒ', tags: { failure_category: 'builder_error', duration_seconds: '42', @@ -121,7 +117,6 @@ describe('emitBuildTransitionEvent', () => { const [, payload] = sendEventToTrackingMock.mock.calls[0] expect(payload).toMatchObject({ event: 'Build Timed Out', - icon: 'โฐ', tags: { failure_category: 'timeout', duration_seconds: '1800', diff --git a/tests/builder-onboarding-telemetry.unit.test.ts b/tests/builder-onboarding-telemetry.unit.test.ts index 2da0030b50..7a6d95cf43 100644 --- a/tests/builder-onboarding-telemetry.unit.test.ts +++ b/tests/builder-onboarding-telemetry.unit.test.ts @@ -32,8 +32,6 @@ describe('trackBuilderOnboardingStep', () => { expect(payload).toMatchObject({ event: 'Builder Onboarding Step', channel: 'builder-onboarding', - icon: '๐Ÿงญ', - notify: false, org_id: 'org-uuid-1', tracking_version: 2, tags: { diff --git a/tests/builder-upload-telemetry.unit.test.ts b/tests/builder-upload-telemetry.unit.test.ts index 801cb17fa3..79de71ba37 100644 --- a/tests/builder-upload-telemetry.unit.test.ts +++ b/tests/builder-upload-telemetry.unit.test.ts @@ -57,8 +57,6 @@ describe('trackBuilderUpload', () => { expect(payload).toMatchObject({ event: 'Builder Upload Started', channel: 'build-lifecycle', - icon: 'โฌ†๏ธ', - notify: false, org_id: 'org-uuid-1', tracking_version: 2, tags: { @@ -89,7 +87,6 @@ describe('trackBuilderUpload', () => { const [, payload] = sendEventMock.mock.calls[0] expect(payload).toMatchObject({ event: 'Builder Upload Succeeded', - icon: '๐Ÿ“ฆ', tags: { platform: 'android', upload_duration_seconds: '43', @@ -114,7 +111,6 @@ describe('trackBuilderUpload', () => { const [, payload] = sendEventMock.mock.calls[0] expect(payload).toMatchObject({ event: 'Builder Upload Failed', - icon: '๐Ÿšซ', tags: { failure_category: 'payload_too_large', upload_duration_seconds: '5', diff --git a/tests/events.test.ts b/tests/events.test.ts index 3460ff6a9f..fc007d55ed 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -38,8 +38,6 @@ describe('[POST] /private/events operations', () => { channel: 'test', event: 'test_event', description: 'Testing event tracking', - icon: '๐Ÿงช', - notify: false, tags: { app_id: APPNAME_EVENT, test: true, @@ -62,8 +60,6 @@ describe('[POST] /private/events operations', () => { channel: 'test', event: 'test_event_v2', description: 'Testing v2 event tracking', - icon: '๐Ÿงช', - notify: false, org_id: ORG_ID, tracking_version: 2, tags: { @@ -87,7 +83,6 @@ describe('[POST] /private/events operations', () => { body: JSON.stringify({ channel: 'onboarding', event: 'onboarding_ai_instructions_copied', - icon: '๐Ÿค–', nonPersonTags: { flow: 'existing_org', onboarding_attempt_id: id, @@ -95,7 +90,6 @@ describe('[POST] /private/events operations', () => { resumed: true, setup_command: 'ota', }, - notify: false, org_id: ORG_ID, tags: { app_id: APPNAME_EVENT, @@ -119,8 +113,6 @@ describe('[POST] /private/events operations', () => { channel: 'test', event: 'test_event_v2_org_scoped', description: 'Testing v2 org-scoped event tracking', - icon: '๐Ÿงช', - notify: false, org_id: ORG_ID, tracking_version: 2, tags: { @@ -169,8 +161,6 @@ describe('[POST] /private/events operations', () => { channel: 'onboarding-v2', event: 'onboarding-step-done', description: 'Testing v2 onboarding completion', - icon: 'โœ…', - notify: false, org_id: ORG_ID, tracking_version: 2, tags: { @@ -194,8 +184,6 @@ describe('[POST] /private/events operations', () => { channel: 'test', event: 'test_event_v2', description: 'Cross-org v2 spoof attempt', - icon: '๐Ÿงช', - notify: false, org_id: NON_OWNER_ORG_ID, tracking_version: 2, tags: { @@ -316,8 +304,6 @@ describe('[POST] /private/events operations', () => { channel: 'test', event: 'test_event', description: 'Testing event tracking', - icon: '๐Ÿงช', - notify: false, tags: { app_id: APPNAME_EVENT, test: true, diff --git a/tests/logsnag-insights-revenue.unit.test.ts b/tests/global-stats-revenue.unit.test.ts similarity index 72% rename from tests/logsnag-insights-revenue.unit.test.ts rename to tests/global-stats-revenue.unit.test.ts index 6e01800ea1..3bdb6ef994 100644 --- a/tests/logsnag-insights-revenue.unit.test.ts +++ b/tests/global-stats-revenue.unit.test.ts @@ -2,8 +2,7 @@ import type { Context } from 'hono' import { readFileSync } from 'node:fs' import { Hono } from 'hono/tiny' import { describe, expect, it, vi } from 'vitest' -import { logsnagInsightsTestUtils } from '../supabase/functions/_backend/triggers/logsnag_insights.ts' -import { logsnagInsights } from '../supabase/functions/_backend/utils/logsnag.ts' +import { globalStatsTestUtils } from '../supabase/functions/_backend/triggers/global_stats.ts' import { sendEventToTracking } from '../supabase/functions/_backend/utils/tracking.ts' function withTestEnv(values: Record) { @@ -23,13 +22,13 @@ function withTestEnv(values: Record) { } } -describe('logsnag revenue metric helpers', () => { +describe('global stats metric helpers', () => { it.concurrent('keeps revenue-active snapshots limited to succeeded subscriptions', () => { - expect(logsnagInsightsTestUtils.REVENUE_ACTIVE_STRIPE_STATUSES).toEqual(['succeeded']) + expect(globalStatsTestUtils.REVENUE_ACTIVE_STRIPE_STATUSES).toEqual(['succeeded']) }) it.concurrent('counts paid customers from paid_at rows and legacy fallback rows', () => { - expect(logsnagInsightsTestUtils.countUniqueCustomers( + expect(globalStatsTestUtils.countUniqueCustomers( [ { customer_id: 'cus_paid_1' }, { customer_id: 'cus_paid_2' }, @@ -41,7 +40,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('deduplicates customer ids across the paid_at query and legacy fallback query', () => { - expect(logsnagInsightsTestUtils.countUniqueCustomers( + expect(globalStatsTestUtils.countUniqueCustomers( [ { customer_id: 'cus_shared' }, ], @@ -52,7 +51,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('clamps trailing 12-month start for leap-day ends', () => { - const start = logsnagInsightsTestUtils.getTrailing12mStart(new Date('2024-02-29T00:00:00.000Z')) + const start = globalStatsTestUtils.getTrailing12mStart(new Date('2024-02-29T00:00:00.000Z')) expect(start.toISOString()).toBe('2023-02-28T00:00:00.000Z') }) @@ -63,18 +62,18 @@ describe('logsnag revenue metric helpers', () => { const paying = 1161 const allOrgs = 7749 - expect(logsnagInsightsTestUtils.calculateConversionRate( + expect(globalStatsTestUtils.calculateConversionRate( priorUpgradedOrgs12m + todayUpgradedOrgs, paying, )).toBe(8.7) - expect(logsnagInsightsTestUtils.calculateConversionRate( + expect(globalStatsTestUtils.calculateConversionRate( priorUpgradedOrgs12m + todayUpgradedOrgs, allOrgs, )).toBe(1.3) }) it.concurrent('computes plan conversion rates against paying orgs, not all users/orgs', () => { - const rates = logsnagInsightsTestUtils.getPlanConversionRates( + const rates = globalStatsTestUtils.getPlanConversionRates( { Solo: 15, Maker: 10, Team: 0, Enterprise: 0, Trial: 50 }, 25, ) @@ -85,11 +84,11 @@ describe('logsnag revenue metric helpers', () => { enterprise: 0, total: 100, }) - expect(logsnagInsightsTestUtils.calculateConversionRate(15, 200)).toBe(7.5) + expect(globalStatsTestUtils.calculateConversionRate(15, 200)).toBe(7.5) }) it.concurrent('builds UTC calendar-day bounds', () => { - const { dayStart, nextDayStart, dayDateId } = logsnagInsightsTestUtils.getCurrentDayWindow(new Date('2026-03-24T18:45:12.000Z')) + const { dayStart, nextDayStart, dayDateId } = globalStatsTestUtils.getCurrentDayWindow(new Date('2026-03-24T18:45:12.000Z')) expect(dayStart.toISOString()).toBe('2026-03-24T00:00:00.000Z') expect(nextDayStart.toISOString()).toBe('2026-03-25T00:00:00.000Z') @@ -97,7 +96,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('builds the previous completed UTC day window for scheduled snapshots', () => { - const { dayStart, nextDayStart, dayDateId } = logsnagInsightsTestUtils.getCompletedDayWindow(new Date('2026-03-25T01:01:00.000Z')) + const { dayStart, nextDayStart, dayDateId } = globalStatsTestUtils.getCompletedDayWindow(new Date('2026-03-25T01:01:00.000Z')) expect(dayStart.toISOString()).toBe('2026-03-24T00:00:00.000Z') expect(nextDayStart.toISOString()).toBe('2026-03-25T00:00:00.000Z') @@ -105,8 +104,8 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('derives replay metric bounds from a preserved snapshot date', () => { - const replayWindow = logsnagInsightsTestUtils.getCompletedDayWindowForDateId('2026-03-24') - const { dayStart, nextDayStart, dayDateId } = logsnagInsightsTestUtils.getMetricWindowFromDailyWindow(replayWindow) + const replayWindow = globalStatsTestUtils.getCompletedDayWindowForDateId('2026-03-24') + const { dayStart, nextDayStart, dayDateId } = globalStatsTestUtils.getMetricWindowFromDailyWindow(replayWindow) expect(dayStart.toISOString()).toBe('2026-03-24T00:00:00.000Z') expect(nextDayStart.toISOString()).toBe('2026-03-25T00:00:00.000Z') @@ -114,8 +113,8 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('delays app build onboarding metrics until the full 24h cohort can complete', () => { - const coreWindow = logsnagInsightsTestUtils.getCompletedDayWindowForDateId('2026-03-24') - const finalizedWindow = logsnagInsightsTestUtils.getCompletedAppBuildOnboardingWindow(coreWindow) + const coreWindow = globalStatsTestUtils.getCompletedDayWindowForDateId('2026-03-24') + const finalizedWindow = globalStatsTestUtils.getCompletedAppBuildOnboardingWindow(coreWindow) expect(finalizedWindow.prevDayStart.toISOString()).toBe('2026-03-23T00:00:00.000Z') expect(finalizedWindow.prevDayEnd.toISOString()).toBe('2026-03-24T00:00:00.000Z') @@ -123,7 +122,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('summarizes app build onboarding daily cohorts', () => { - expect(logsnagInsightsTestUtils.summarizeAppBuildOnboardingRows([ + expect(globalStatsTestUtils.summarizeAppBuildOnboardingRows([ { created_at: '2026-03-24T10:00:00.000Z', created_from_onboarding: true, @@ -156,12 +155,12 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('builds a bounded recent repair window for missing global stats days', () => { - expect(logsnagInsightsTestUtils.buildRecentGlobalStatsRepairDateIds('2026-06-29', 2)).toEqual([ + expect(globalStatsTestUtils.buildRecentGlobalStatsRepairDateIds('2026-06-29', 2)).toEqual([ '2026-06-27', '2026-06-28', '2026-06-29', ]) - expect(logsnagInsightsTestUtils.buildRecentGlobalStatsRepairDateIds('2026-03-01', 2)).toEqual([ + expect(globalStatsTestUtils.buildRecentGlobalStatsRepairDateIds('2026-03-01', 2)).toEqual([ '2026-02-27', '2026-02-28', '2026-03-01', @@ -181,10 +180,19 @@ describe('logsnag revenue metric helpers', () => { expect(definition!).not.toContain('file_size') }) + it.concurrent('registers the native notification shard on both trigger runtimes', () => { + const cloudflareRouter = readFileSync(new URL('../cloudflare_workers/api/index.ts', import.meta.url), 'utf8') + const supabaseRouter = readFileSync(new URL('../supabase/functions/triggers/index.ts', import.meta.url), 'utf8') + const route = "route('/global_stats_native_notifications', globalStatsShardApps.native_notifications)" + + expect(cloudflareRouter).toContain(route) + expect(supabaseRouter).toContain(route) + }) + it.concurrent('detects missing global stats shards before notifications', () => { - expect(logsnagInsightsTestUtils.getMissingGlobalStatsRequiredShards(new Set())).toEqual([ + expect(globalStatsTestUtils.getMissingGlobalStatsRequiredShards(new Set())).toEqual([ 'core', - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, 'revenue', 'plugins', 'builds', @@ -193,9 +201,9 @@ describe('logsnag revenue metric helpers', () => { 'ltv', ]) - const completed = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const completed = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, 'plugins', 'builds', 'retention', @@ -205,12 +213,12 @@ describe('logsnag revenue metric helpers', () => { 'bad', ]) - expect(logsnagInsightsTestUtils.getMissingGlobalStatsRequiredShards(completed)).toEqual(['revenue']) - expect(logsnagInsightsTestUtils.getGlobalStatsShardQueueCandidates(completed)).toEqual(['revenue']) + expect(globalStatsTestUtils.getMissingGlobalStatsRequiredShards(completed)).toEqual(['revenue']) + expect(globalStatsTestUtils.getGlobalStatsShardQueueCandidates(completed)).toEqual(['revenue']) - const ready = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const ready = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, 'revenue', 'plugins', 'builds', @@ -218,11 +226,11 @@ describe('logsnag revenue metric helpers', () => { 'paid_products', 'ltv', ]) - expect(logsnagInsightsTestUtils.getMissingGlobalStatsRequiredShards(ready)).toEqual([]) - expect(logsnagInsightsTestUtils.getMissingGlobalStatsShards(ready)).toEqual(['notifications', 'native_notifications']) - expect(logsnagInsightsTestUtils.getGlobalStatsShardQueueCandidates(ready)).toEqual(['notifications', 'native_notifications']) + expect(globalStatsTestUtils.getMissingGlobalStatsRequiredShards(ready)).toEqual([]) + expect(globalStatsTestUtils.getMissingGlobalStatsShards(ready)).toEqual(['notifications', 'native_notifications']) + expect(globalStatsTestUtils.getGlobalStatsShardQueueCandidates(ready)).toEqual(['notifications', 'native_notifications']) - const legacyUsage = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const legacyUsage = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', 'usage', 'revenue', @@ -232,22 +240,22 @@ describe('logsnag revenue metric helpers', () => { 'paid_products', 'ltv', ]) - expect(logsnagInsightsTestUtils.getMissingGlobalStatsRequiredShards(legacyUsage)).toEqual([ - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + expect(globalStatsTestUtils.getMissingGlobalStatsRequiredShards(legacyUsage)).toEqual([ + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, ]) - const sent = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const sent = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ ...ready, 'notifications', 'native_notifications', ]) - expect(logsnagInsightsTestUtils.getMissingGlobalStatsShards(sent)).toEqual([]) + expect(globalStatsTestUtils.getMissingGlobalStatsShards(sent)).toEqual([]) }) it.concurrent('requeues stale completed global stats shards before notifications', () => { - const ready = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const ready = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, 'revenue', 'plugins', 'builds', @@ -273,17 +281,17 @@ describe('logsnag revenue metric helpers', () => { counts: { ios: 32, android: 0 }, } - const staleShards = logsnagInsightsTestUtils.getGlobalStatsStaleRepairShards(staleRow, expectedBuildStats) + const staleShards = globalStatsTestUtils.getGlobalStatsStaleRepairShards(staleRow, expectedBuildStats) expect(staleShards).toEqual(['core', 'usage_storage', 'builds']) - expect(logsnagInsightsTestUtils.getGlobalStatsRepairShardQueueCandidates(ready, staleShards)).toEqual(['core', 'usage_storage', 'builds']) - expect(logsnagInsightsTestUtils.getGlobalStatsRepairShardQueueCandidates(ready)).toEqual(['notifications', 'native_notifications']) + expect(globalStatsTestUtils.getGlobalStatsRepairShardQueueCandidates(ready, staleShards)).toEqual(['core', 'usage_storage', 'builds']) + expect(globalStatsTestUtils.getGlobalStatsRepairShardQueueCandidates(ready)).toEqual(['notifications', 'native_notifications']) }) it.concurrent('keeps fresh completed global stats shards eligible for notifications', () => { - const ready = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const ready = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, 'revenue', 'plugins', 'builds', @@ -309,16 +317,16 @@ describe('logsnag revenue metric helpers', () => { counts: { ios: 32, android: 0 }, } - const staleShards = logsnagInsightsTestUtils.getGlobalStatsStaleRepairShards(freshRow, expectedBuildStats) + const staleShards = globalStatsTestUtils.getGlobalStatsStaleRepairShards(freshRow, expectedBuildStats) expect(staleShards).toEqual([]) - expect(logsnagInsightsTestUtils.getGlobalStatsRepairShardQueueCandidates(ready, staleShards)).toEqual(['notifications', 'native_notifications']) + expect(globalStatsTestUtils.getGlobalStatsRepairShardQueueCandidates(ready, staleShards)).toEqual(['notifications', 'native_notifications']) }) it.concurrent('detects completed global stats notifications for idempotent retries', () => { - const ready = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const ready = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, 'revenue', 'plugins', 'builds', @@ -326,49 +334,48 @@ describe('logsnag revenue metric helpers', () => { 'paid_products', 'ltv', ]) - expect(logsnagInsightsTestUtils.hasCompletedGlobalStatsNotifications(ready)).toBe(false) + expect(globalStatsTestUtils.hasCompletedGlobalStatsNotifications(ready)).toBe(false) - const sent = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const sent = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ ...ready, 'notifications', ]) - expect(logsnagInsightsTestUtils.hasCompletedGlobalStatsNotifications(sent)).toBe(true) + expect(globalStatsTestUtils.hasCompletedGlobalStatsNotifications(sent)).toBe(true) - const partiallySent = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const partiallySent = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ ...ready, - 'notifications_logsnag', 'notifications_tracking', ]) - expect(logsnagInsightsTestUtils.hasCompletedGlobalStatsNotifications(partiallySent)).toBe(false) - expect(logsnagInsightsTestUtils.getMissingGlobalStatsShards(partiallySent)).toEqual(['notifications', 'native_notifications']) + expect(globalStatsTestUtils.hasCompletedGlobalStatsNotifications(partiallySent)).toBe(false) + expect(globalStatsTestUtils.getMissingGlobalStatsShards(partiallySent)).toEqual(['notifications', 'native_notifications']) }) it.concurrent('skips completed non-notification shard retries only', () => { - const completed = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const completed = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', 'notifications', ]) - expect(logsnagInsightsTestUtils.shouldSkipCompletedGlobalStatsShardRetry(completed, 'core')).toBe(true) - expect(logsnagInsightsTestUtils.shouldSkipCompletedGlobalStatsShardRetry(completed, 'usage_updates')).toBe(false) - expect(logsnagInsightsTestUtils.shouldSkipCompletedGlobalStatsShardRetry(completed, 'notifications')).toBe(false) + expect(globalStatsTestUtils.shouldSkipCompletedGlobalStatsShardRetry(completed, 'core')).toBe(true) + expect(globalStatsTestUtils.shouldSkipCompletedGlobalStatsShardRetry(completed, 'usage_updates')).toBe(false) + expect(globalStatsTestUtils.shouldSkipCompletedGlobalStatsShardRetry(completed, 'notifications')).toBe(false) }) it.concurrent('derives only missing global stats shards for partial dispatcher retries', () => { - const partial = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const partial = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, 'revenue', ]) - expect(logsnagInsightsTestUtils.getMissingGlobalStatsRequiredShards(partial)).toEqual([ + expect(globalStatsTestUtils.getMissingGlobalStatsRequiredShards(partial)).toEqual([ 'plugins', 'builds', 'retention', 'paid_products', 'ltv', ]) - expect(logsnagInsightsTestUtils.getMissingGlobalStatsShards(partial)).toEqual([ + expect(globalStatsTestUtils.getMissingGlobalStatsShards(partial)).toEqual([ 'plugins', 'builds', 'retention', @@ -380,9 +387,9 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('uses notification claim markers to avoid replaying claimed sends', () => { - const ready = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const ready = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ 'core', - ...logsnagInsightsTestUtils.USAGE_GLOBAL_STATS_SHARDS, + ...globalStatsTestUtils.USAGE_GLOBAL_STATS_SHARDS, 'revenue', 'plugins', 'builds', @@ -391,23 +398,27 @@ describe('logsnag revenue metric helpers', () => { 'ltv', ]) - expect(logsnagInsightsTestUtils.getGlobalStatsNotificationStepAction(ready, 'notifications_logsnag', 'notifications_logsnag_claim')).toBe('send') + expect(globalStatsTestUtils.getGlobalStatsNotificationStepAction(ready, 'notifications_tracking', 'notifications_tracking_claim')).toBe('send') - const claimed = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const claimed = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ ...ready, - 'notifications_logsnag_claim', + 'notifications_tracking_claim', ]) - expect(logsnagInsightsTestUtils.getGlobalStatsNotificationStepAction(claimed, 'notifications_logsnag', 'notifications_logsnag_claim')).toBe('complete_claimed') + expect(globalStatsTestUtils.getGlobalStatsNotificationStepAction(claimed, 'notifications_tracking', 'notifications_tracking_claim')).toBe('complete_claimed') - const sent = logsnagInsightsTestUtils.normalizeCompletedGlobalStatsShards([ + const sent = globalStatsTestUtils.normalizeCompletedGlobalStatsShards([ ...claimed, - 'notifications_logsnag', + 'notifications_tracking', ]) - expect(logsnagInsightsTestUtils.getGlobalStatsNotificationStepAction(sent, 'notifications_logsnag', 'notifications_logsnag_claim')).toBe('skip') + expect(globalStatsTestUtils.getGlobalStatsNotificationStepAction(sent, 'notifications_tracking', 'notifications_tracking_claim')).toBe('skip') + }) + + it.concurrent('keeps the legacy notification lock namespace during rolling deployments', () => { + expect(globalStatsTestUtils.GLOBAL_STATS_NOTIFICATION_LOCK_NAMESPACE).toBe('logsnag_insights_notifications') }) it.concurrent('computes NRR from prior MRR, churn, contraction, and expansion', () => { - expect(logsnagInsightsTestUtils.calculateNrr(100, { + expect(globalStatsTestUtils.calculateNrr(100, { churnMrr: 15, contractionMrr: 5, expansionMrr: 10, @@ -415,7 +426,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('defaults NRR to 100 when there is no starting MRR baseline', () => { - expect(logsnagInsightsTestUtils.calculateNrr(0, { + expect(globalStatsTestUtils.calculateNrr(0, { churnMrr: 12, contractionMrr: 4, expansionMrr: 0, @@ -423,7 +434,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('sums full churn and downgrade revenue into the churn revenue metric', () => { - expect(logsnagInsightsTestUtils.calculateChurnRevenue({ + expect(globalStatsTestUtils.calculateChurnRevenue({ churnMrr: 18.25, contractionMrr: 7.75, expansionMrr: 0, @@ -431,7 +442,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('calculates current past-due org count and average days', () => { - expect(logsnagInsightsTestUtils.calculatePastDueOrgStats([ + expect(globalStatsTestUtils.calculatePastDueOrgStats([ { customer_id: 'cus_due_1', past_due_at: '2026-03-20T00:00:00.000Z', @@ -454,7 +465,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('ignores future past-due rows and uses the earliest start per customer', () => { - expect(logsnagInsightsTestUtils.calculatePastDueOrgStats([ + expect(globalStatsTestUtils.calculatePastDueOrgStats([ { customer_id: 'cus_due_1', past_due_at: '2026-03-24T00:00:00.000Z', @@ -477,7 +488,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('falls back to updated_at for past-due duration during rollout', () => { - expect(logsnagInsightsTestUtils.calculatePastDueOrgStats([ + expect(globalStatsTestUtils.calculatePastDueOrgStats([ { customer_id: 'cus_due_rollout', past_due_at: null, @@ -492,7 +503,7 @@ describe('logsnag revenue metric helpers', () => { it.concurrent('counts active canceled and active past due orgs at a snapshot boundary', () => { const snapshotEnd = new Date('2026-03-25T00:00:00.000Z') - expect(logsnagInsightsTestUtils.calculateSubscriptionAccessSnapshotCounts([ + expect(globalStatsTestUtils.calculateSubscriptionAccessSnapshotCounts([ { customer_id: 'cus_canceled_active', is_good_plan: true, @@ -539,7 +550,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('normalizes subscription access snapshot SQL rows', () => { - expect(logsnagInsightsTestUtils.normalizeSubscriptionAccessSnapshotCounts({ + expect(globalStatsTestUtils.normalizeSubscriptionAccessSnapshotCounts({ active_canceled_orgs: '3', active_past_due_orgs: null, })).toEqual({ @@ -549,23 +560,23 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('only refreshes mutable past-due stats for the current daily snapshot or an empty first fill', () => { - const currentWindow = logsnagInsightsTestUtils.getCompletedDayWindowForDateId('2026-03-24') + const currentWindow = globalStatsTestUtils.getCompletedDayWindowForDateId('2026-03-24') const replayReferenceDate = new Date('2026-03-26T00:00:00.000Z') - expect(logsnagInsightsTestUtils.shouldRefreshMutablePastDueStats( + expect(globalStatsTestUtils.shouldRefreshMutablePastDueStats( currentWindow, new Date('2026-03-25T12:00:00.000Z'), )).toBe(true) - expect(logsnagInsightsTestUtils.shouldRefreshMutablePastDueStats( + expect(globalStatsTestUtils.shouldRefreshMutablePastDueStats( currentWindow, replayReferenceDate, )).toBe(false) - expect(logsnagInsightsTestUtils.shouldRefreshMutablePastDueStats( + expect(globalStatsTestUtils.shouldRefreshMutablePastDueStats( currentWindow, replayReferenceDate, { past_due_orgs: 0, past_due_orgs_average_days: 0, active_canceled_orgs: 0, active_past_due_orgs: 0 }, )).toBe(true) - expect(logsnagInsightsTestUtils.shouldRefreshMutablePastDueStats( + expect(globalStatsTestUtils.shouldRefreshMutablePastDueStats( currentWindow, replayReferenceDate, { past_due_orgs: 2, past_due_orgs_average_days: 3.8, active_canceled_orgs: 0, active_past_due_orgs: 0 }, @@ -573,7 +584,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('defaults missing plan buckets to zero for global stats snapshots', () => { - expect(logsnagInsightsTestUtils.normalizePlanTotals({ Solo: 12, Team: Number.NaN })).toEqual({ + expect(globalStatsTestUtils.normalizePlanTotals({ Solo: 12, Team: Number.NaN })).toEqual({ Credits: 0, Enterprise: 0, Maker: 0, @@ -586,24 +597,24 @@ describe('logsnag revenue metric helpers', () => { it.concurrent('keeps converted trials in replay snapshots until paid_at reaches the snapshot end', () => { const snapshotEnd = new Date('2026-03-25T00:00:00.000Z') - expect(logsnagInsightsTestUtils.isUnpaidAtBillingSnapshot(null, snapshotEnd)).toBe(true) - expect(logsnagInsightsTestUtils.isUnpaidAtBillingSnapshot('2026-03-25T00:00:00.000Z', snapshotEnd)).toBe(true) - expect(logsnagInsightsTestUtils.isUnpaidAtBillingSnapshot('2026-03-25T00:00:00.001Z', snapshotEnd)).toBe(true) - expect(logsnagInsightsTestUtils.isUnpaidAtBillingSnapshot('2026-03-24T23:59:59.999Z', snapshotEnd)).toBe(false) + expect(globalStatsTestUtils.isUnpaidAtBillingSnapshot(null, snapshotEnd)).toBe(true) + expect(globalStatsTestUtils.isUnpaidAtBillingSnapshot('2026-03-25T00:00:00.000Z', snapshotEnd)).toBe(true) + expect(globalStatsTestUtils.isUnpaidAtBillingSnapshot('2026-03-25T00:00:00.001Z', snapshotEnd)).toBe(true) + expect(globalStatsTestUtils.isUnpaidAtBillingSnapshot('2026-03-24T23:59:59.999Z', snapshotEnd)).toBe(false) }) it.concurrent('excludes unpaid trials from paid replay snapshots', () => { const snapshotEnd = new Date('2026-03-25T00:00:00.000Z') - expect(logsnagInsightsTestUtils.isPaidPlanAtBillingSnapshot(null, '2026-03-26T00:00:00.000Z', snapshotEnd)).toBe(false) - expect(logsnagInsightsTestUtils.isPaidPlanAtBillingSnapshot(null, '2026-03-25T00:00:00.000Z', snapshotEnd)).toBe(false) - expect(logsnagInsightsTestUtils.isPaidPlanAtBillingSnapshot(null, '2026-03-24T23:59:59.999Z', snapshotEnd)).toBe(false) - expect(logsnagInsightsTestUtils.isPaidPlanAtBillingSnapshot('2026-03-25T00:00:00.000Z', '2026-03-24T00:00:00.000Z', snapshotEnd)).toBe(false) - expect(logsnagInsightsTestUtils.isPaidPlanAtBillingSnapshot('2026-03-24T23:59:59.999Z', '2026-03-26T00:00:00.000Z', snapshotEnd)).toBe(true) + expect(globalStatsTestUtils.isPaidPlanAtBillingSnapshot(null, '2026-03-26T00:00:00.000Z', snapshotEnd)).toBe(false) + expect(globalStatsTestUtils.isPaidPlanAtBillingSnapshot(null, '2026-03-25T00:00:00.000Z', snapshotEnd)).toBe(false) + expect(globalStatsTestUtils.isPaidPlanAtBillingSnapshot(null, '2026-03-24T23:59:59.999Z', snapshotEnd)).toBe(false) + expect(globalStatsTestUtils.isPaidPlanAtBillingSnapshot('2026-03-25T00:00:00.000Z', '2026-03-24T00:00:00.000Z', snapshotEnd)).toBe(false) + expect(globalStatsTestUtils.isPaidPlanAtBillingSnapshot('2026-03-24T23:59:59.999Z', '2026-03-26T00:00:00.000Z', snapshotEnd)).toBe(true) }) it.concurrent('resolves billing interval from price ids then anchor length', () => { - expect(logsnagInsightsTestUtils.resolvePlanBillingInterval({ + expect(globalStatsTestUtils.resolvePlanBillingInterval({ priceId: 'price_m', priceMId: 'price_m', priceYId: 'price_y', @@ -611,7 +622,7 @@ describe('logsnag revenue metric helpers', () => { anchorEnd: '2027-01-01T00:00:00.000Z', })).toBe('monthly') - expect(logsnagInsightsTestUtils.resolvePlanBillingInterval({ + expect(globalStatsTestUtils.resolvePlanBillingInterval({ priceId: 'price_y', priceMId: 'price_m', priceYId: 'price_y', @@ -619,7 +630,7 @@ describe('logsnag revenue metric helpers', () => { anchorEnd: '2026-02-01T00:00:00.000Z', })).toBe('yearly') - expect(logsnagInsightsTestUtils.resolvePlanBillingInterval({ + expect(globalStatsTestUtils.resolvePlanBillingInterval({ priceId: 'price_custom', priceMId: 'price_m', priceYId: 'price_y', @@ -627,7 +638,7 @@ describe('logsnag revenue metric helpers', () => { anchorEnd: '2026-12-01T00:00:00.000Z', })).toBe('yearly') - expect(logsnagInsightsTestUtils.resolvePlanBillingInterval({ + expect(globalStatsTestUtils.resolvePlanBillingInterval({ priceId: null, priceMId: 'price_m', priceYId: 'price_y', @@ -637,7 +648,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('resolves MRR from matched price ids with list-price fallback', () => { - expect(logsnagInsightsTestUtils.resolvePlanMrrDollars({ + expect(globalStatsTestUtils.resolvePlanMrrDollars({ billing: 'monthly', priceId: 'price_m', priceMId: 'price_m', @@ -646,7 +657,7 @@ describe('logsnag revenue metric helpers', () => { priceY: 146, })).toBe(14) - expect(logsnagInsightsTestUtils.resolvePlanMrrDollars({ + expect(globalStatsTestUtils.resolvePlanMrrDollars({ billing: 'yearly', priceId: 'price_y', priceMId: 'price_m', @@ -655,7 +666,7 @@ describe('logsnag revenue metric helpers', () => { priceY: 146, })).toBe(146 / 12) - expect(logsnagInsightsTestUtils.resolvePlanMrrDollars({ + expect(globalStatsTestUtils.resolvePlanMrrDollars({ billing: 'yearly', priceId: 'price_custom', priceMId: 'price_m', @@ -669,14 +680,14 @@ describe('logsnag revenue metric helpers', () => { const snapshotEnd = new Date('2026-08-11T00:00:00.000Z') // Matches SQL `si.trial_at <= snapshot` (NULL does not qualify). - expect(logsnagInsightsTestUtils.hasLeftTrialAtSnapshot(null, snapshotEnd)).toBe(false) - expect(logsnagInsightsTestUtils.hasLeftTrialAtSnapshot('2026-08-10T23:59:59.999Z', snapshotEnd)).toBe(true) - expect(logsnagInsightsTestUtils.hasLeftTrialAtSnapshot('2026-08-11T00:00:00.000Z', snapshotEnd)).toBe(true) - expect(logsnagInsightsTestUtils.hasLeftTrialAtSnapshot('2026-08-11T00:00:00.001Z', snapshotEnd)).toBe(false) + expect(globalStatsTestUtils.hasLeftTrialAtSnapshot(null, snapshotEnd)).toBe(false) + expect(globalStatsTestUtils.hasLeftTrialAtSnapshot('2026-08-10T23:59:59.999Z', snapshotEnd)).toBe(true) + expect(globalStatsTestUtils.hasLeftTrialAtSnapshot('2026-08-11T00:00:00.000Z', snapshotEnd)).toBe(true) + expect(globalStatsTestUtils.hasLeftTrialAtSnapshot('2026-08-11T00:00:00.001Z', snapshotEnd)).toBe(false) }) it.concurrent('normalizes snapshot billing counts from SQL rows', () => { - expect(logsnagInsightsTestUtils.normalizeBillingSnapshotCounts([ + expect(globalStatsTestUtils.normalizeBillingSnapshotCounts([ { yearly: '2', monthly: '3', @@ -716,7 +727,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('defaults empty snapshot billing rows to zero counts', () => { - expect(logsnagInsightsTestUtils.normalizeBillingSnapshotCounts([])).toEqual({ + expect(globalStatsTestUtils.normalizeBillingSnapshotCounts([])).toEqual({ customers: { yearly: 0, monthly: 0, total: 0 }, payingOrgsForConversion: 0, plans: { @@ -731,7 +742,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('normalizes core snapshot counts from SQL rows', () => { - expect(logsnagInsightsTestUtils.normalizeCoreSnapshotCounts({ + expect(globalStatsTestUtils.normalizeCoreSnapshotCounts({ onboarded: '7', need_upgrade: null, above_plan_with_credits: '4', @@ -743,7 +754,7 @@ describe('logsnag revenue metric helpers', () => { abovePlanWithoutCredits: 0, }) - expect(logsnagInsightsTestUtils.normalizeCoreSnapshotCounts(null)).toEqual({ + expect(globalStatsTestUtils.normalizeCoreSnapshotCounts(null)).toEqual({ onboarded: 0, needUpgrade: 0, abovePlanWithCredits: 0, @@ -752,7 +763,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('reconstructs above-plan credit state at the replayed snapshot boundary', () => { - const source = readFileSync(new URL('../supabase/functions/_backend/triggers/logsnag_insights.ts', import.meta.url), 'utf8') + const source = readFileSync(new URL('../supabase/functions/_backend/triggers/global_stats.ts', import.meta.url), 'utf8') const remainingCreditsHelper = source.match(/function remainingCreditsAtSnapshotSql[\s\S]*?\n\}/)?.[0] ?? '' const coreSnapshotQuery = source.match(/async function getCoreSnapshotCounts[\s\S]*?async function runCoreGlobalStatsShard/)?.[0] ?? '' @@ -767,7 +778,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('counts credit-only orgs as a daily plan bucket at the replayed snapshot boundary', () => { - const source = readFileSync(new URL('../supabase/functions/_backend/triggers/logsnag_insights.ts', import.meta.url), 'utf8') + const source = readFileSync(new URL('../supabase/functions/_backend/triggers/global_stats.ts', import.meta.url), 'utf8') const billingSnapshotQuery = source.match(/async function getBillingSnapshotCounts[\s\S]*?async function getSubscriptionAccessSnapshotCounts/)?.[0] ?? '' const coreShard = source.match(/async function runCoreGlobalStatsShard[\s\S]*?async function getRegistersToday/)?.[0] ?? '' @@ -783,7 +794,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('shares remaining-credits snapshot predicate between billing and core snapshots', () => { - const source = readFileSync(new URL('../supabase/functions/_backend/triggers/logsnag_insights.ts', import.meta.url), 'utf8') + const source = readFileSync(new URL('../supabase/functions/_backend/triggers/global_stats.ts', import.meta.url), 'utf8') const helperMatches = source.match(/remainingCreditsAtSnapshotSql\(/g) ?? [] expect(source).toContain('function remainingCreditsAtSnapshotSql') @@ -791,7 +802,7 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('snapshots apps with preview QR enabled in the core global stats shard', () => { - const source = readFileSync(new URL('../supabase/functions/_backend/triggers/logsnag_insights.ts', import.meta.url), 'utf8') + const source = readFileSync(new URL('../supabase/functions/_backend/triggers/global_stats.ts', import.meta.url), 'utf8') const countFn = source.match(/async function countAppsWithPreview[\s\S]*?async function getTrialExtensionStats/)?.[0] ?? '' const coreShard = source.match(/async function runCoreGlobalStatsShard[\s\S]*?async function getRegistersToday/)?.[0] ?? '' @@ -804,16 +815,16 @@ describe('logsnag revenue metric helpers', () => { expect(source).toContain('apps_with_preview?: number') expect(source).toContain('isMissingAppsWithPreviewColumnError') }) - it.concurrent('normalizes logsnag insights retry payload counts', () => { - expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsRetryCount('2')).toBe(2) - expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsRetryCount(2.8)).toBe(2) - expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsRetryCount(-1)).toBe(0) - expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsRetryCount('bad')).toBe(0) + it.concurrent('normalizes global stats retry payload counts', () => { + expect(globalStatsTestUtils.normalizeGlobalStatsRetryCount('2')).toBe(2) + expect(globalStatsTestUtils.normalizeGlobalStatsRetryCount(2.8)).toBe(2) + expect(globalStatsTestUtils.normalizeGlobalStatsRetryCount(-1)).toBe(0) + expect(globalStatsTestUtils.normalizeGlobalStatsRetryCount('bad')).toBe(0) }) it.concurrent('builds retry messages for the admin stats queue', () => { - expect(logsnagInsightsTestUtils.buildLogsnagInsightsRetryMessage(3)).toEqual({ - function_name: 'logsnag_insights', + expect(globalStatsTestUtils.buildGlobalStatsRetryMessage(3)).toEqual({ + function_name: 'global_stats', function_type: 'cloudflare', payload: { retry_count: 3, @@ -822,8 +833,8 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('preserves the snapshot date on dispatcher retry messages', () => { - expect(logsnagInsightsTestUtils.buildLogsnagInsightsRetryMessage(3, '2026-03-24')).toEqual({ - function_name: 'logsnag_insights', + expect(globalStatsTestUtils.buildGlobalStatsRetryMessage(3, '2026-03-24')).toEqual({ + function_name: 'global_stats', function_type: 'cloudflare', payload: { date_id: '2026-03-24', @@ -833,17 +844,17 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('builds shard messages as distinct queue HTTP calls', () => { - expect(logsnagInsightsTestUtils.getLogsnagInsightsShardFunctionName('revenue')).toBe('logsnag_insights_revenue') - expect(logsnagInsightsTestUtils.getLogsnagInsightsShardFunctionName('usage_updates')).toBe('logsnag_insights_usage_updates') - expect(logsnagInsightsTestUtils.buildLogsnagInsightsShardMessage('revenue', '2026-03-24')).toEqual({ - function_name: 'logsnag_insights_revenue', + expect(globalStatsTestUtils.getGlobalStatsShardFunctionName('revenue')).toBe('global_stats_revenue') + expect(globalStatsTestUtils.getGlobalStatsShardFunctionName('usage_updates')).toBe('global_stats_usage_updates') + expect(globalStatsTestUtils.buildGlobalStatsShardMessage('revenue', '2026-03-24')).toEqual({ + function_name: 'global_stats_revenue', function_type: 'cloudflare', payload: { date_id: '2026-03-24', }, }) - expect(logsnagInsightsTestUtils.buildLogsnagInsightsShardMessage('revenue', '2026-03-24', 2)).toEqual({ - function_name: 'logsnag_insights_revenue', + expect(globalStatsTestUtils.buildGlobalStatsShardMessage('revenue', '2026-03-24', 2)).toEqual({ + function_name: 'global_stats_revenue', function_type: 'cloudflare', payload: { date_id: '2026-03-24', @@ -853,19 +864,19 @@ describe('logsnag revenue metric helpers', () => { }) it.concurrent('normalizes global stats shard and date payloads', () => { - expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsShard('core')).toBe('core') - expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsShard('usage_updates')).toBe('usage_updates') - expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsShard('usage')).toBeNull() - expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsShard('bad')).toBeNull() - expect(logsnagInsightsTestUtils.normalizeGlobalStatsDateId('2026-03-24')).toBe('2026-03-24') - expect(logsnagInsightsTestUtils.normalizeGlobalStatsDateId('2026-02-30')).toBeNull() - expect(logsnagInsightsTestUtils.normalizeGlobalStatsDateId('bad')).toBeNull() + expect(globalStatsTestUtils.normalizeGlobalStatsShard('core')).toBe('core') + expect(globalStatsTestUtils.normalizeGlobalStatsShard('usage_updates')).toBe('usage_updates') + expect(globalStatsTestUtils.normalizeGlobalStatsShard('usage')).toBeNull() + expect(globalStatsTestUtils.normalizeGlobalStatsShard('bad')).toBeNull() + expect(globalStatsTestUtils.normalizeGlobalStatsDateId('2026-03-24')).toBe('2026-03-24') + expect(globalStatsTestUtils.normalizeGlobalStatsDateId('2026-02-30')).toBeNull() + expect(globalStatsTestUtils.normalizeGlobalStatsDateId('bad')).toBeNull() }) it('rejects non-empty malformed JSON payloads', async () => { const app = new Hono() app.post('/', async (c) => { - await logsnagInsightsTestUtils.readLogsnagInsightsPayload(c) + await globalStatsTestUtils.readGlobalStatsPayload(c) return c.json({ status: 'ok' }) }) @@ -897,7 +908,7 @@ describe('logsnag revenue metric helpers', () => { const app = new Hono() const runUpdate = vi.fn(() => updatePromise) app.post('/', async (c) => { - await logsnagInsightsTestUtils.scheduleLogsnagInsightsUpdate(c, runUpdate) + await globalStatsTestUtils.scheduleGlobalStatsUpdate(c, runUpdate) return c.json({ status: 'ok' }) }) @@ -946,7 +957,7 @@ describe('logsnag revenue metric helpers', () => { const runShard = vi.fn((_c: Context, _shard: string, _dateId: string) => shardPromise) const cancelRetry = vi.fn(async (_c: Context, _retryMsgId: number) => {}) app.post('/', async (c) => { - await logsnagInsightsTestUtils.scheduleLogsnagInsightsShardUpdate(c, 'core', '2026-03-24', { + await globalStatsTestUtils.scheduleGlobalStatsShardUpdate(c, 'core', '2026-03-24', { cancelRetry, retryCount: 1, retryMsgId: 654, @@ -1001,7 +1012,7 @@ describe('logsnag revenue metric helpers', () => { }) const cancelRetry = vi.fn(async (_c: Context, _retryMsgId: number) => {}) app.post('/', async (c) => { - await logsnagInsightsTestUtils.scheduleLogsnagInsightsShardUpdate(c, 'core', '2026-03-24', { + await globalStatsTestUtils.scheduleGlobalStatsShardUpdate(c, 'core', '2026-03-24', { cancelRetry, retryCount: 1, retryMsgId: 654, @@ -1041,8 +1052,8 @@ describe('logsnag revenue metric helpers', () => { throw new Error('shard failed after retry budget') }) app.post('/', async (c) => { - await logsnagInsightsTestUtils.scheduleLogsnagInsightsShardUpdate(c, 'core', '2026-03-24', { - retryCount: logsnagInsightsTestUtils.LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES, + await globalStatsTestUtils.scheduleGlobalStatsShardUpdate(c, 'core', '2026-03-24', { + retryCount: globalStatsTestUtils.GLOBAL_STATS_BACKGROUND_MAX_RETRIES, retryMsgId: null, runShard, }) @@ -1077,7 +1088,7 @@ describe('logsnag revenue metric helpers', () => { const runUpdate = vi.fn(async () => {}) const cancelRetry = vi.fn(async (_c: Context, _retryMsgId: number) => {}) app.post('/', async (c) => { - await logsnagInsightsTestUtils.scheduleLogsnagInsightsUpdate(c, runUpdate, { + await globalStatsTestUtils.scheduleGlobalStatsUpdate(c, runUpdate, { cancelRetry, retryCount: 2, retryMsgId: 321, @@ -1121,7 +1132,7 @@ describe('logsnag revenue metric helpers', () => { throw cancelFailure }) app.post('/', async (c) => { - await logsnagInsightsTestUtils.scheduleLogsnagInsightsUpdate(c, runUpdate, { + await globalStatsTestUtils.scheduleGlobalStatsUpdate(c, runUpdate, { cancelRetry, retryCount: 2, retryMsgId: 321, @@ -1165,7 +1176,7 @@ describe('logsnag revenue metric helpers', () => { }) const cancelRetry = vi.fn(async (_c: Context, _retryMsgId: number) => {}) app.post('/', async (c) => { - await logsnagInsightsTestUtils.scheduleLogsnagInsightsUpdate(c, runUpdate, { + await globalStatsTestUtils.scheduleGlobalStatsUpdate(c, runUpdate, { cancelRetry, retryCount: 2, retryMsgId: 321, @@ -1203,8 +1214,8 @@ describe('logsnag revenue metric helpers', () => { throw new Error('snapshot failed after retry budget') }) app.post('/', async (c) => { - await logsnagInsightsTestUtils.scheduleLogsnagInsightsUpdate(c, runUpdate, { - retryCount: logsnagInsightsTestUtils.LOGSNAG_INSIGHTS_BACKGROUND_MAX_RETRIES, + await globalStatsTestUtils.scheduleGlobalStatsUpdate(c, runUpdate, { + retryCount: globalStatsTestUtils.GLOBAL_STATS_BACKGROUND_MAX_RETRIES, retryMsgId: null, }) return c.json({ status: 'ok' }) @@ -1222,7 +1233,6 @@ describe('logsnag revenue metric helpers', () => { it('propagates strict tracking provider failures', async () => { const restoreEnv = withTestEnv({ - LOGSNAG_TOKEN: '', POSTHOG_API_KEY: '', }) @@ -1245,23 +1255,4 @@ describe('logsnag revenue metric helpers', () => { } }) - it('propagates strict LogSnag insights delivery failures', async () => { - const restoreEnv = withTestEnv({ - LOGSNAG_TOKEN: '', - LOGSNAG_PROJECT: '', - }) - - const c = { - get: () => undefined, - } as unknown as Context - - try { - await expect(logsnagInsights(c, [ - { title: 'Apps', value: 1, icon: '๐Ÿ“ฑ' }, - ], { strict: true })).rejects.toThrow('LogSnag insights is not configured') - } - finally { - restoreEnv() - } - }) }) diff --git a/tests/plans-visit-tracking.unit.test.ts b/tests/plans-visit-tracking.unit.test.ts index a612f35625..cea206a07f 100644 --- a/tests/plans-visit-tracking.unit.test.ts +++ b/tests/plans-visit-tracking.unit.test.ts @@ -18,10 +18,8 @@ describe('plans visit tracking', () => { expect(sender).toHaveBeenCalledWith({ channel: 'usage', event: 'User visit', - icon: '๐Ÿ’ณ', org_id: 'org-1', tracking_version: 2, - notify: false, tags: { page: 'plans' }, }) }) diff --git a/tests/queue_load.test.ts b/tests/queue_load.test.ts index 6e9ed98dcf..b7321fc37d 100644 --- a/tests/queue_load.test.ts +++ b/tests/queue_load.test.ts @@ -56,9 +56,9 @@ describe('queue Load Test', () => { await fetchQueueSync(queueName) }) - it('should queue delayed messages with the same PGMQ send shape used by logsnag insights retries', async () => { + it('should queue delayed messages with the same PGMQ send shape used by global stats retries', async () => { const retryMessage = { - function_name: 'logsnag_insights', + function_name: 'global_stats', function_type: 'cloudflare', payload: { date_id: '2099-01-01', @@ -74,6 +74,14 @@ describe('queue Load Test', () => { expect(Number.isSafeInteger(Number(result.rows[0]?.msg_id))).toBe(true) }) + it('should enqueue the provider-neutral global stats route from the admin stats cron function', async () => { + const result = await pool.query<{ definition: string }>( + `SELECT pg_get_functiondef('public.process_admin_stats()'::regprocedure) AS definition`, + ) + + expect(result.rows[0]?.definition).toMatch(/'function_name'\s*,\s*'global_stats'/) + }) + it.concurrent('should reject invalid queue sync requests', async () => { // Test missing queue_name const invalidResponse1 = await fetch(`${BASE_URL_TRIGGER}/queue_consumer/sync`, { diff --git a/tests/tracking.unit.test.ts b/tests/tracking.unit.test.ts index 7d7c5dde44..682d6eda08 100644 --- a/tests/tracking.unit.test.ts +++ b/tests/tracking.unit.test.ts @@ -6,14 +6,12 @@ const { drizzleClientMock, pgClientEndMock, pgClientMock, - logsnagTrackMock, notifToOrgMembersMock, posthogMock, } = vi.hoisted(() => ({ backgroundTaskMock: vi.fn(), cloudlogErrMock: vi.fn(), drizzleClientMock: { mocked: true }, - logsnagTrackMock: vi.fn(), notifToOrgMembersMock: vi.fn(), pgClientEndMock: vi.fn().mockResolvedValue(undefined), pgClientMock: { mocked: true, end: vi.fn().mockResolvedValue(undefined) }, @@ -24,12 +22,6 @@ vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ backgroundTask: backgroundTaskMock, })) -vi.mock('../supabase/functions/_backend/utils/logsnag.ts', () => ({ - logsnag: () => ({ - track: logsnagTrackMock, - }), -})) - vi.mock('../supabase/functions/_backend/utils/posthog.ts', () => ({ trackPosthogEvent: posthogMock, })) @@ -61,7 +53,6 @@ beforeEach(() => { backgroundTaskMock.mockImplementation((_c: unknown, promise: Promise) => promise) notifToOrgMembersMock.mockResolvedValue(true) pgClientEndMock.mockResolvedValue(undefined) - logsnagTrackMock.mockResolvedValue(true) posthogMock.mockResolvedValue(true) }) @@ -70,7 +61,6 @@ afterEach(() => { backgroundTaskMock.mockReset() notifToOrgMembersMock.mockReset() pgClientEndMock.mockReset() - logsnagTrackMock.mockReset() posthogMock.mockReset() cloudlogErrMock.mockReset() }) @@ -82,7 +72,6 @@ describe('sendEventToTracking', () => { const payload = addAuthenticatedApiKeyIdToTrackingPayload({ channel: 'usage', event: 'Tracked Event', - notify: false, tags: { apikey_id: 'caller-supplied', app_id: 'app-id' }, }, 87015) @@ -96,7 +85,6 @@ describe('sendEventToTracking', () => { const payload = addAuthenticatedApiKeyIdToTrackingPayload({ channel: 'usage', event: 'Tracked Event', - notify: false, tags: { apikey_id: 'caller-supplied', app_id: 'app-id' }, nonPersonTags: { apikey_id: 'caller-supplied', cli_version: '8.31.3' }, }, undefined) @@ -105,7 +93,7 @@ describe('sendEventToTracking', () => { expect(payload.nonPersonTags).toEqual({ cli_version: '8.31.3' }) }) - it('runs all tracking providers in the background by default', async () => { + it('runs PostHog and Bento in the background by default', async () => { const { sendEventToTracking } = await import('../supabase/functions/_backend/utils/tracking.ts') await sendEventToTracking(createContext(), { @@ -120,14 +108,12 @@ describe('sendEventToTracking', () => { event: 'Tracked Event', user_id: 'org-id', description: 'test description', - notify: false, sentToBento: true, tags: { app_id: 'app-id' }, nonPersonTags: { apikey_id: 87015 }, }) expect(backgroundTaskMock).toHaveBeenCalledTimes(2) - expect(logsnagTrackMock).toHaveBeenCalledWith(expect.objectContaining({ event: 'Tracked Event' })) expect(posthogMock).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ event: 'Tracked Event', ip: '1.2.3.4', @@ -147,28 +133,94 @@ describe('sendEventToTracking', () => { ) }) - it('can run inline and keeps other providers running when one fails', async () => { - logsnagTrackMock.mockRejectedValueOnce(new Error('logsnag failed')) + it('can run inline and keeps Bento running when PostHog fails', async () => { + posthogMock.mockRejectedValueOnce(new Error('posthog failed')) const { sendEventToTracking } = await import('../supabase/functions/_backend/utils/tracking.ts') await sendEventToTracking(createContext(), { + bento: { + data: { org_id: 'org-id' }, + event: 'org:inline', + preferenceKey: 'onboarding', + uniqId: 'org:inline', + }, channel: 'usage', event: 'Inline Event', user_id: 'org-id', - notify: true, + sentToBento: true, }, { background: false, }) expect(backgroundTaskMock).not.toHaveBeenCalled() expect(posthogMock).toHaveBeenCalledOnce() + expect(notifToOrgMembersMock).toHaveBeenCalledOnce() expect(cloudlogErrMock).toHaveBeenCalledWith(expect.objectContaining({ message: 'sendEventToTracking provider failed', - provider: 'logsnag', + provider: 'posthog', + })) + }) + + it.each([ + ['a Date', new Date('2026-08-18T08:15:30.000Z')], + ['a numeric timestamp', Date.parse('2026-08-18T09:45:00.000Z')], + ])('preserves %s when forwarding events to PostHog', async (_label, timestamp) => { + const { sendEventToTracking } = await import('../supabase/functions/_backend/utils/tracking.ts') + + await sendEventToTracking(createContext(), { + channel: 'usage', + event: 'Timestamped Event', + timestamp, + }, { background: false }) + + expect(posthogMock).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + timestamp: new Date(timestamp).toISOString(), + })) + }) + + it.each([ + ['an invalid Date', new Date(Number.NaN)], + ['NaN', Number.NaN], + ['positive infinity', Number.POSITIVE_INFINITY], + ['an out-of-range timestamp', 8.64e15 + 1], + ])('drops %s instead of failing strict tracking', async (_label, timestamp) => { + const { sendEventToTracking } = await import('../supabase/functions/_backend/utils/tracking.ts') + + await expect(sendEventToTracking(createContext(), { + channel: 'usage', + event: 'Invalid Timestamp Event', + timestamp, + }, { background: false, strict: true })).resolves.toBeUndefined() + + expect(posthogMock).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + timestamp: undefined, + })) + }) + + it('ignores legacy presentation and notification fields instead of copying them into PostHog metadata', async () => { + const { sendEventToTracking } = await import('../supabase/functions/_backend/utils/tracking.ts') + + await sendEventToTracking(createContext(), { + channel: 'usage', + event: 'Legacy Presentation Event', + icon: '๐Ÿงช', + nonPersonTags: { cli_version: '8.31.3' }, + notify: true, + parser: 'markdown', + } as any, { background: false }) + + expect(posthogMock).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + nonPersonTags: { + cli_version: '8.31.3', + }, })) + const posthogPayload = posthogMock.mock.calls[0]?.[1] + expect(posthogPayload).not.toHaveProperty('icon') + expect(posthogPayload).not.toHaveProperty('notify') + expect(posthogPayload).not.toHaveProperty('parser') }) - it('can skip PostHog while preserving LogSnag and Bento delivery', async () => { + it('can skip PostHog while preserving Bento delivery', async () => { const { sendEventToTracking } = await import('../supabase/functions/_backend/utils/tracking.ts') await sendEventToTracking(createContext(), { @@ -180,12 +232,10 @@ describe('sendEventToTracking', () => { }, channel: 'onboarding', event: 'onboarding_ai_instructions_copied', - notify: false, sentToBento: true, user_id: 'org-id', }, { background: false, posthog: false }) - expect(logsnagTrackMock).toHaveBeenCalledOnce() expect(posthogMock).not.toHaveBeenCalled() expect(notifToOrgMembersMock).toHaveBeenCalledOnce() })