diff --git a/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts b/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts index 2604211fc4e2..902350d39c13 100644 --- a/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts +++ b/packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts @@ -1,11 +1,11 @@ import { createLogger, LOG_LEVELS, Logger, LogLevel } from "@fern-api/logger"; import { + type CaptureExceptionOptions, type CliError, type CreateInteractiveTaskParams, type Finishable, type InteractiveTaskContext, type PosthogEvent, - resolveErrorCode, type Startable, TaskAbortSignal, type TaskContext, @@ -79,9 +79,8 @@ export class TaskContextAdapter implements TaskContext { return this.lastFailureMessage; } - public captureException(error: unknown, code?: CliError.Code): void { - const errorCode = resolveErrorCode(error, code); - this.context.telemetry.captureException(error, { errorCode }); + public captureException(error: unknown, options?: CaptureExceptionOptions): string | undefined { + return this.context.telemetry.captureException(error, options); } private getFullErrorMessage(message?: string, error?: unknown): string | undefined { diff --git a/packages/cli/cli-v2/src/context/withContext.ts b/packages/cli/cli-v2/src/context/withContext.ts index 39d62bdc022f..35b12414e1b7 100644 --- a/packages/cli/cli-v2/src/context/withContext.ts +++ b/packages/cli/cli-v2/src/context/withContext.ts @@ -126,7 +126,9 @@ export function reportError( const capturable = error ?? new CliError({ message: options?.message ?? "", code }); if (shouldReportToSentry(code)) { context.telemetry.captureException(capturable, { - errorCode: code + tags: { + "error.code": code + } }); } context.telemetry.sendLifecycleEvent({ diff --git a/packages/cli/cli-v2/src/telemetry/TelemetryClient.ts b/packages/cli/cli-v2/src/telemetry/TelemetryClient.ts index b8357f058c38..73437377ab05 100644 --- a/packages/cli/cli-v2/src/telemetry/TelemetryClient.ts +++ b/packages/cli/cli-v2/src/telemetry/TelemetryClient.ts @@ -1,6 +1,6 @@ import { getRunIdProperties, setSentryRunIdTags } from "@fern-api/cli-telemetry"; import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; -import { CliError } from "@fern-api/task-context"; +import { type CaptureExceptionOptions, CliError } from "@fern-api/task-context"; import * as Sentry from "@sentry/node"; import { mkdir, readFile, writeFile } from "fs/promises"; import IS_CI from "is-ci"; @@ -131,19 +131,26 @@ export class TelemetryClient { * The caller is responsible for deciding which errors are worth reporting * (see `shouldReportToSentry` in withContext.ts). */ - public captureException(error: unknown, { errorCode }: { errorCode: string }): void { + public captureException(error: unknown, options?: CaptureExceptionOptions): string | undefined { if (this.sentry === undefined) { - return; + return undefined; } try { - this.sentry.captureException(error, { - captureContext: { - user: { id: this.distinctId }, - tags: { ...this.baseTags, ...this.accumulatedTags, "error.code": errorCode } + const tags = { ...this.baseTags, ...this.accumulatedTags, ...options?.tags }; + const context = options?.context; + return Sentry.withScope((scope) => { + scope.setTags(tags); + if (context != null) { + for (const [key, value] of Object.entries(context)) { + if (value != null) { + scope.setContext(key, value); + } + } } + return this.sentry?.captureException(error, undefined, scope); }); } catch { - // no-op + return undefined; } } diff --git a/packages/cli/cli/changes/5.27.0/automation-mode-telemetry.yml b/packages/cli/cli/changes/5.27.0/automation-mode-telemetry.yml new file mode 100644 index 000000000000..8254b733f4fa --- /dev/null +++ b/packages/cli/cli/changes/5.27.0/automation-mode-telemetry.yml @@ -0,0 +1,7 @@ +- summary: | + Enrich CLI telemetry with automation-mode context (config repo, branch, + commit sha, PR number, trigger, and GitHub run details) on every PostHog + event and Sentry error report. + Add the three-leg failure flow (Sentry + PostHog + automation event API) + so automation-run failures surface end-to-end. + type: internal diff --git a/packages/cli/cli/changes/5.27.1/fatal-error-telemetry.yml b/packages/cli/cli/changes/5.27.1/fatal-error-telemetry.yml new file mode 100644 index 000000000000..70c9139a45f7 --- /dev/null +++ b/packages/cli/cli/changes/5.27.1/fatal-error-telemetry.yml @@ -0,0 +1,3 @@ +- summary: | + Report escaped fatal CLI errors through telemetry in packaged production runs. + type: fix diff --git a/packages/cli/cli/src/cli-context/CliContext.ts b/packages/cli/cli/src/cli-context/CliContext.ts index dd263b1c83a4..130d27a49f17 100644 --- a/packages/cli/cli/src/cli-context/CliContext.ts +++ b/packages/cli/cli/src/cli-context/CliContext.ts @@ -1,12 +1,14 @@ import { Log, logErrorMessage, TtyAwareLogger } from "@fern-api/cli-logger"; import { createLogger, LOG_LEVELS, LogLevel } from "@fern-api/logger"; -import { getPosthogManager, PosthogManager } from "@fern-api/posthog-manager"; +import { getPosthogManager, type PosthogManager } from "@fern-api/posthog-manager"; import { Project } from "@fern-api/project-loader"; import { isVersionAhead } from "@fern-api/semver-utils"; import { + type CaptureExceptionOptions, CliError, Finishable, - PosthogEvent, + type PosthogAutomationEvent, + type PosthogEvent, Startable, TaskAbortSignal, TaskContext, @@ -17,6 +19,11 @@ import { Workspace } from "@fern-api/workspace-loader"; import { input, select } from "@inquirer/prompts"; import chalk from "chalk"; import { maxBy } from "lodash-es"; +import { + type AutomationTelemetryEmitOptions, + AutomationTelemetryManager +} from "../telemetry/AutomationTelemetryManager.js"; +import type { AutomationTelemetryEvent } from "../telemetry/automationTelemetryEvent.js"; import { reportError } from "../telemetry/reportError.js"; import { SentryClient } from "../telemetry/SentryClient.js"; import { CliEnvironment } from "./CliEnvironment.js"; @@ -42,6 +49,7 @@ export class CliContext { public readonly environment: CliEnvironment; private readonly sentryClient: SentryClient; private readonly posthogManager: PosthogManager; + private readonly automationTelemetryManager: AutomationTelemetryManager; private didSucceed = true; @@ -82,7 +90,15 @@ export class CliContext { packageVersion, cliName }; - this.sentryClient = new SentryClient({ release: `cli@${this.environment.packageVersion}` }); + this.sentryClient = new SentryClient({ + release: `cli@${this.environment.packageVersion}`, + telemetry: { + cliName: this.environment.cliName, + packageVersion: this.environment.packageVersion, + isLocal: this.isLocal + } + }); + this.automationTelemetryManager = new AutomationTelemetryManager(this); } private getPackageName() { @@ -173,6 +189,7 @@ export class CliContext { // Silently swallow – analytics should never block the CLI } await this.sentryClient.flush(); + await this.automationTelemetryManager.flush(); this.exitProgram({ code }); } @@ -222,6 +239,7 @@ export class CliContext { private project: Project | undefined; public registerProject(project: Project): void { this.project = project; + this.automationTelemetryManager.setOrganization(project.config.organization); } public runTask(run: (context: TaskContext) => T | Promise): Promise { @@ -271,8 +289,21 @@ export class CliContext { } } - public captureException(error: unknown, code?: CliError.Code): void { - this.sentryClient.captureException(error, code); + public instrumentPostHogAutomationEvent(event: PosthogAutomationEvent): void { + if (!this.isLocal) { + this.posthogManager.sendAutomationEvent(event); + } + } + + public captureException(error: unknown, options?: CaptureExceptionOptions): string | undefined { + return this.sentryClient.captureException(error, options); + } + + public emitAutomationTelemetryEvent( + event: AutomationTelemetryEvent, + options?: AutomationTelemetryEmitOptions + ): void { + this.automationTelemetryManager.emit(event, options); } public readonly logger = createLogger((level, ...args) => this.log(level, ...args)); @@ -316,9 +347,8 @@ export class CliContext { this.instrumentPostHogEvent(event); }, shouldBufferLogs: false, - captureException: (error, code) => { - this.sentryClient.captureException(error, code); - } + captureException: (error, options) => this.sentryClient.captureException(error, options), + emitAutomationTelemetryEvent: (event, options) => this.emitAutomationTelemetryEvent(event, options) }; } diff --git a/packages/cli/cli/src/cli-context/TaskContextImpl.ts b/packages/cli/cli/src/cli-context/TaskContextImpl.ts index 7843a2e1483d..bc6529afadf2 100644 --- a/packages/cli/cli/src/cli-context/TaskContextImpl.ts +++ b/packages/cli/cli/src/cli-context/TaskContextImpl.ts @@ -2,6 +2,7 @@ import { Log, logErrorMessage } from "@fern-api/cli-logger"; import { addPrefixToString } from "@fern-api/core-utils"; import { createLogger, LogLevel } from "@fern-api/logger"; import { + type CaptureExceptionOptions, CliError, CreateInteractiveTaskParams, Finishable, @@ -15,6 +16,8 @@ import { import chalk from "chalk"; +import type { AutomationTelemetryEmitOptions } from "../telemetry/AutomationTelemetryManager.js"; +import type { AutomationTelemetryEvent } from "../telemetry/automationTelemetryEvent.js"; import { reportError } from "../telemetry/reportError.js"; export declare namespace TaskContextImpl { @@ -33,7 +36,11 @@ export declare namespace TaskContextImpl { onResult?: (result: TaskResult) => void; shouldBufferLogs: boolean; instrumentPostHogEvent: (event: PosthogEvent) => void; - captureException?: (error: unknown, code?: CliError.Code) => void; + captureException: (error: unknown, options?: CaptureExceptionOptions) => string | undefined; + emitAutomationTelemetryEvent: ( + event: AutomationTelemetryEvent, + options?: AutomationTelemetryEmitOptions + ) => void; } } @@ -49,7 +56,11 @@ export class TaskContextImpl implements Startable, Finishable, Task protected status: "notStarted" | "running" | "finished" = "notStarted"; private onResult: ((result: TaskResult) => void) | undefined; private instrumentPostHogEventImpl: (event: PosthogEvent) => void; - private captureExceptionImpl?: (error: unknown, code?: CliError.Code) => void; + private captureExceptionImpl: (error: unknown, options?: CaptureExceptionOptions) => string | undefined; + private emitAutomationTelemetryEventImpl: ( + event: AutomationTelemetryEvent, + options?: AutomationTelemetryEmitOptions + ) => void; public constructor({ logImmediately, logPrefix, @@ -58,7 +69,8 @@ export class TaskContextImpl implements Startable, Finishable, Task onResult, shouldBufferLogs, instrumentPostHogEvent, - captureException + captureException, + emitAutomationTelemetryEvent }: TaskContextImpl.Init) { this.logImmediately = logImmediately; this.logPrefix = logPrefix ?? ""; @@ -68,6 +80,7 @@ export class TaskContextImpl implements Startable, Finishable, Task this.shouldBufferLogs = shouldBufferLogs; this.instrumentPostHogEventImpl = instrumentPostHogEvent; this.captureExceptionImpl = captureException; + this.emitAutomationTelemetryEventImpl = emitAutomationTelemetryEvent; } public start(): Finishable & TaskContext { @@ -116,8 +129,15 @@ export class TaskContextImpl implements Startable, Finishable, Task return this.lastFailureMessage; } - public captureException(error: unknown, code?: CliError.Code): void { - this.captureExceptionImpl?.(error, code); + public captureException(error: unknown, options?: CaptureExceptionOptions): string | undefined { + return this.captureExceptionImpl?.(error, options); + } + + public emitAutomationTelemetryEvent( + event: AutomationTelemetryEvent, + options?: AutomationTelemetryEmitOptions + ): void { + this.emitAutomationTelemetryEventImpl(event, options); } public getResult(): TaskResult { @@ -168,7 +188,8 @@ export class TaskContextImpl implements Startable, Finishable, Task onResult: this.onResult, shouldBufferLogs: this.shouldBufferLogs, instrumentPostHogEvent: (event) => this.instrumentPostHogEventImpl(event), - captureException: this.captureExceptionImpl + captureException: this.captureExceptionImpl, + emitAutomationTelemetryEvent: this.emitAutomationTelemetryEventImpl }); this.subtasks.push(subtask); return subtask; diff --git a/packages/cli/cli/src/cli-context/__test__/TaskContextImpl.test.ts b/packages/cli/cli/src/cli-context/__test__/TaskContextImpl.test.ts index 690b9b347958..7b58d0bfeb36 100644 --- a/packages/cli/cli/src/cli-context/__test__/TaskContextImpl.test.ts +++ b/packages/cli/cli/src/cli-context/__test__/TaskContextImpl.test.ts @@ -16,7 +16,9 @@ function createContext(): { context: TaskContextImpl; getLogs: () => Log[] } { await run(); }, shouldBufferLogs: false, - instrumentPostHogEvent: () => undefined + instrumentPostHogEvent: () => undefined, + captureException: () => undefined, + emitAutomationTelemetryEvent: () => undefined }); return { context, getLogs: () => allLogs }; } @@ -33,7 +35,9 @@ function createInteractiveContext(): { subtask: InteractiveTaskContextImpl; getL await run(); }, shouldBufferLogs: false, - instrumentPostHogEvent: () => undefined + instrumentPostHogEvent: () => undefined, + captureException: () => undefined, + emitAutomationTelemetryEvent: () => undefined }); return { subtask, getLogs: () => allLogs }; } diff --git a/packages/cli/cli/src/cli.ts b/packages/cli/cli/src/cli.ts index e53d216bb0ff..4b76ac50fa23 100644 --- a/packages/cli/cli/src/cli.ts +++ b/packages/cli/cli/src/cli.ts @@ -102,6 +102,7 @@ import { FERN_CWD_ENV_VAR } from "./cwd.js"; import { rerunFernCliAtVersion } from "./rerunFernCliAtVersion.js"; import { resolveGroupGithubConfig } from "./resolveGroupGithubConfig.js"; import { RUNTIME } from "./runtime.js"; +import { installProcessHandlers } from "./telemetry/processHandlers.js"; // Node 26+ on Linux enables io_uring in libuv, which has a busy-loop bug that // hangs the process. UV_USE_IO_URING must be set before Node starts (libuv @@ -142,6 +143,10 @@ async function runCli() { cliContext.suppressUpgradeMessage(); } + // In packaged CLI runs, escaped errors (uncaughtException, unhandledRejection) + // would otherwise exit without firing and flushing telemetry. + installProcessHandlers(cliContext, { isLocal }); + const exit = async () => { await cliContext.exit(); }; diff --git a/packages/cli/cli/src/commands/generate-overrides/__test__/writeOverridesForWorkspaces.test.ts b/packages/cli/cli/src/commands/generate-overrides/__test__/writeOverridesForWorkspaces.test.ts index 46ff723c2d0f..a5a7173c12a9 100644 --- a/packages/cli/cli/src/commands/generate-overrides/__test__/writeOverridesForWorkspaces.test.ts +++ b/packages/cli/cli/src/commands/generate-overrides/__test__/writeOverridesForWorkspaces.test.ts @@ -24,7 +24,7 @@ function createMockContext(): TaskContext { throw new Error(message ?? "Task failed"); }, failWithoutThrowing: noop, - captureException: noop, + captureException: () => undefined, getResult: () => TaskResult.Success, getLastFailureMessage: () => undefined, addInteractiveTask: () => { diff --git a/packages/cli/cli/src/telemetry/AutomationEventApiClient.ts b/packages/cli/cli/src/telemetry/AutomationEventApiClient.ts new file mode 100644 index 000000000000..98e63b58a98e --- /dev/null +++ b/packages/cli/cli/src/telemetry/AutomationEventApiClient.ts @@ -0,0 +1,103 @@ +/** + * POSTs automation events to the automation event API at + * `/v1/automation/events` - the failure path that feeds Slack delivery. + * + * Best-effort by design: failures here are swallowed so the parallel + * Sentry and PostHog writes still surface the incident. + */ +import { getAccessToken } from "@fern-api/auth"; +import type { AutomationTelemetryContext } from "./automationTelemetryContext.js"; +import type { AutomationTelemetryEvent } from "./automationTelemetryEvent.js"; +import { toAutomationEventApiBody } from "./automationTelemetryEvent.js"; + +/** + * Origin override. Keep the default empty until the automation event API origin + * is ready to receive CLI automation events in production. + */ +const DEFAULT_AUTOMATION_EVENTS_ORIGIN = ""; +const AUTOMATION_EVENTS_ORIGIN_ENV_VAR = "FERN_AUTOMATION_EVENTS_ORIGIN"; + +/** + * How long to wait for the automation event API before giving up. The CLI's + * exit path cannot afford to hang on a slow Slack dispatcher, so we cap + * aggressively. + */ +const POST_TIMEOUT_MS = 3000; + +export class AutomationEventApiClient { + private static instance: AutomationEventApiClient | undefined; + + private readonly origin: string; + private inflight: Promise[] = []; + + public static getInstance(): AutomationEventApiClient { + if (AutomationEventApiClient.instance == null) { + AutomationEventApiClient.instance = new AutomationEventApiClient( + process.env[AUTOMATION_EVENTS_ORIGIN_ENV_VAR] ?? DEFAULT_AUTOMATION_EVENTS_ORIGIN + ); + } + return AutomationEventApiClient.instance; + } + + private constructor(origin: string) { + this.origin = origin; + } + + /** + * Resolves the automation event API endpoint from the baked origin. + * Returns undefined when no origin is configured - callers silently skip + * the POST in that case. + */ + public resolveEndpoint(): string | undefined { + if (this.origin.length === 0) { + return undefined; + } + return `${this.origin.replace(/\/$/, "")}/v1/automation/events`; + } + + /** + * Enqueues an automation event POST to the automation event API. + * + * Best-effort: on any failure (timeout, DNS, non-2xx, missing endpoint), + * the request settles without throwing. Sentry + PostHog already captured + * the incident. + */ + public post(event: AutomationTelemetryEvent, context: AutomationTelemetryContext): void { + this.inflight.push(this.send(event, context)); + } + + public async shutdown(): Promise { + const pending = this.inflight; + this.inflight = []; + if (pending.length > 0) { + await Promise.allSettled(pending); + } + } + + private async send(event: AutomationTelemetryEvent, context: AutomationTelemetryContext): Promise { + const endpoint = this.resolveEndpoint(); + if (endpoint == null) { + return; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS); + try { + const headers: Record = { "content-type": "application/json" }; + const token = await getAccessToken(); + if (token != null && token.value.length > 0) { + headers.Authorization = `Bearer ${token.value}`; + } + const response = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify(toAutomationEventApiBody(event, context)), + signal: controller.signal + }); + void response; + } catch { + return; + } finally { + clearTimeout(timer); + } + } +} diff --git a/packages/cli/cli/src/telemetry/AutomationTelemetryManager.ts b/packages/cli/cli/src/telemetry/AutomationTelemetryManager.ts new file mode 100644 index 000000000000..b05c31ee008e --- /dev/null +++ b/packages/cli/cli/src/telemetry/AutomationTelemetryManager.ts @@ -0,0 +1,91 @@ +import type { CaptureExceptionOptions, PosthogAutomationEvent } from "@fern-api/task-context"; +import { AutomationEventApiClient } from "./AutomationEventApiClient.js"; +import { type AutomationTelemetryContext, getAutomationContextFromEnv } from "./automationTelemetryContext.js"; +import { + type AutomationTelemetryEvent, + isFailureAutomationEventName, + toPosthogProperties, + toSentryContext, + toSentryTags +} from "./automationTelemetryEvent.js"; + +export interface AutomationTelemetryReporter { + instrumentPostHogAutomationEvent: (event: PosthogAutomationEvent) => void; + captureException: (error: unknown, options?: CaptureExceptionOptions) => string | undefined; +} + +export interface AutomationTelemetryEmitOptions { + error?: unknown; +} + +export class AutomationTelemetryManager { + private context: AutomationTelemetryContext; + + public constructor( + private readonly reporter: AutomationTelemetryReporter, + private readonly automationEventApiClient: AutomationEventApiClient = AutomationEventApiClient.getInstance() + ) { + this.context = getAutomationContextFromEnv(); + } + + public setOrganization(organization: string): void { + this.context = { ...this.context, org: organization }; + } + + public emit(event: AutomationTelemetryEvent, options?: AutomationTelemetryEmitOptions): void { + const sentryEventId = this.captureSentryForFailure({ + event, + context: this.context, + error: options?.error + }); + + const eventWithSentry = + sentryEventId == null + ? event + : { ...event, attributes: { ...(event.attributes ?? {}), sentry_event_id: sentryEventId } }; + this.capturePostHogEvent({ event: eventWithSentry, context: this.context }); + this.captureAutomationEventApiEvent(eventWithSentry, this.context); + } + + public async flush(): Promise { + await this.automationEventApiClient.shutdown(); + } + + private capturePostHogEvent({ + event, + context + }: { + event: AutomationTelemetryEvent; + context: AutomationTelemetryContext; + }): void { + this.reporter.instrumentPostHogAutomationEvent({ + distinctId: context.run_id ?? undefined, + event: event.event, + properties: toPosthogProperties(event, context) + }); + } + + private captureSentryForFailure({ + event, + context, + error + }: { + event: AutomationTelemetryEvent; + context: AutomationTelemetryContext; + error?: unknown; + }): string | undefined { + if (!isFailureAutomationEventName(event.event) || event.errorCode == null) { + return undefined; + } + return this.reporter.captureException(error, { + tags: toSentryTags(event, context), + context: { + automation: toSentryContext(event, context) + } + }); + } + + private captureAutomationEventApiEvent(event: AutomationTelemetryEvent, context: AutomationTelemetryContext): void { + this.automationEventApiClient.post(event, context); + } +} diff --git a/packages/cli/cli/src/telemetry/SentryClient.ts b/packages/cli/cli/src/telemetry/SentryClient.ts index 00c349663c78..9bf6ecdbc6c8 100644 --- a/packages/cli/cli/src/telemetry/SentryClient.ts +++ b/packages/cli/cli/src/telemetry/SentryClient.ts @@ -1,15 +1,15 @@ import { setSentryRunIdTags } from "@fern-api/cli-telemetry"; -import { CliError } from "@fern-api/task-context"; +import { type CaptureExceptionOptions, CliError } from "@fern-api/task-context"; import * as Sentry from "@sentry/node"; -import { isTelemetryDisabled } from "./isTelemetryDisabled.js"; +import { shouldInitializeTelemetry, type TelemetryInitializationOptions } from "./shouldInitializeTelemetry.js"; export class SentryClient { private readonly sentry: Sentry.NodeClient | undefined; - constructor({ release }: { release: string }) { + constructor({ release, telemetry }: { release: string; telemetry: TelemetryInitializationOptions }) { const sentryDsn = process.env.SENTRY_DSN; - if (!isTelemetryDisabled() && sentryDsn != null && sentryDsn.length > 0) { + if (shouldInitializeTelemetry(telemetry) && sentryDsn != null && sentryDsn.length > 0) { const sentryEnvironment = process.env.SENTRY_ENVIRONMENT; if (sentryEnvironment == null || sentryEnvironment.length === 0) { throw new CliError({ @@ -53,17 +53,33 @@ export class SentryClient { } } - public captureException(error: unknown, code?: string): void { + public captureException(error: unknown, options?: CaptureExceptionOptions): string | undefined { if (this.sentry == null) { - return; + return undefined; } try { - this.sentry.captureException( - error, - code != null ? { captureContext: { tags: { "error.code": code } } } : undefined - ); + const tags = options?.tags; + const context = options?.context; + const hasTags = tags != null && Object.keys(tags).length > 0; + const hasContext = context != null && Object.values(context).some((value) => value != null); + if (!hasTags && !hasContext) { + return this.sentry.captureException(error); + } + return Sentry.withScope((scope) => { + if (hasTags) { + scope.setTags(tags); + } + if (context != null) { + for (const [key, value] of Object.entries(context)) { + if (value != null) { + scope.setContext(key, value); + } + } + } + return this.sentry?.captureException(error, undefined, scope); + }); } catch { - // no-op + return undefined; } } diff --git a/packages/cli/cli/src/telemetry/__test__/SentryClient.test.ts b/packages/cli/cli/src/telemetry/__test__/SentryClient.test.ts index 491071dc1cad..b3b2737074d0 100644 --- a/packages/cli/cli/src/telemetry/__test__/SentryClient.test.ts +++ b/packages/cli/cli/src/telemetry/__test__/SentryClient.test.ts @@ -1,18 +1,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockSentryCaptureException, mockSentryFlush, mockSentryInit } = vi.hoisted(() => ({ - mockSentryCaptureException: vi.fn(), - mockSentryFlush: vi.fn().mockResolvedValue(true), - mockSentryInit: vi.fn(function () { - return { - captureException: mockSentryCaptureException, - flush: mockSentryFlush - }; - }) -})); +const { mockSentryCaptureException, mockSentryFlush, mockSentryInit, mockSentrySetContext, mockSentrySetTags } = + vi.hoisted(() => ({ + mockSentryCaptureException: vi.fn(), + mockSentryFlush: vi.fn().mockResolvedValue(true), + mockSentryInit: vi.fn(function () { + return { + captureException: mockSentryCaptureException, + flush: mockSentryFlush + }; + }), + mockSentrySetContext: vi.fn(), + mockSentrySetTags: vi.fn() + })); vi.mock("@sentry/node", () => ({ init: mockSentryInit, + withScope: vi.fn( + ( + callback: (scope: { + setContext: (key: string, context: Record) => void; + setTags: (tags: Record) => void; + }) => unknown + ) => callback({ setContext: mockSentrySetContext, setTags: mockSentrySetTags }) + ), rewriteFramesIntegration: vi.fn().mockReturnValue({}), onUncaughtExceptionIntegration: vi.fn().mockReturnValue({}), onUnhandledRejectionIntegration: vi.fn().mockReturnValue({}), @@ -24,14 +35,27 @@ vi.mock("@sentry/node", () => ({ import { CliError } from "@fern-api/task-context"; import { SentryClient } from "../SentryClient.js"; +function createSentryClient({ isLocal = false }: { isLocal?: boolean } = {}): SentryClient { + return new SentryClient({ + release: "cli@1.2.3", + telemetry: { + cliName: "fern", + packageVersion: "1.2.3", + isLocal + } + }); +} + describe("SentryClient (cli-v1)", () => { beforeEach(() => { process.env.SENTRY_DSN = "https://example@sentry.io/123"; - process.env.SENTRY_ENVIRONMENT = "test"; + process.env.SENTRY_ENVIRONMENT = "production"; delete process.env.FERN_DISABLE_TELEMETRY; mockSentryCaptureException.mockClear(); mockSentryFlush.mockClear(); mockSentryInit.mockClear(); + mockSentrySetContext.mockClear(); + mockSentrySetTags.mockClear(); }); afterEach(() => { @@ -43,35 +67,53 @@ describe("SentryClient (cli-v1)", () => { it("does not initialize Sentry when telemetry is disabled", () => { process.env.FERN_DISABLE_TELEMETRY = "true"; - // eslint-disable-next-line no-new - new SentryClient({ release: "cli@1.2.3" }); + createSentryClient(); + + expect(mockSentryInit).not.toHaveBeenCalled(); + }); + + it("does not initialize Sentry when running local generation", () => { + createSentryClient({ isLocal: true }); expect(mockSentryInit).not.toHaveBeenCalled(); }); it("captures exceptions with Sentry when enabled", async () => { - const client = new SentryClient({ release: "cli@1.2.3" }); + const client = createSentryClient(); await client.captureException(new Error("boom")); expect(mockSentryInit).toHaveBeenCalledOnce(); expect(mockSentryCaptureException).toHaveBeenCalledOnce(); - expect(mockSentryCaptureException).toHaveBeenCalledWith(expect.any(Error), undefined); + expect(mockSentryCaptureException).toHaveBeenCalledWith(expect.any(Error)); }); - it("passes error.code tag via captureContext when code is provided", () => { - const client = new SentryClient({ release: "cli@1.2.3" }); + it("sets tags on a Sentry scope when provided", () => { + const client = createSentryClient(); const error = new Error("something broke"); - client.captureException(error, CliError.Code.InternalError); + client.captureException(error, { tags: { "error.code": CliError.Code.InternalError } }); + + expect(mockSentrySetTags).toHaveBeenCalledWith({ "error.code": CliError.Code.InternalError }); + expect(mockSentryCaptureException).toHaveBeenCalledWith(error, undefined, expect.objectContaining({})); + }); + + it("sets context on a Sentry scope when provided", () => { + const client = createSentryClient(); + const error = new Error("something broke"); + + client.captureException(error, { + context: { automation: { github_run_url: "https://github.com/acme/repo/actions/runs/1" } } + }); - expect(mockSentryCaptureException).toHaveBeenCalledWith(error, { - captureContext: { tags: { "error.code": CliError.Code.InternalError } } + expect(mockSentrySetContext).toHaveBeenCalledWith("automation", { + github_run_url: "https://github.com/acme/repo/actions/runs/1" }); + expect(mockSentryCaptureException).toHaveBeenCalledWith(error, undefined, expect.objectContaining({})); }); it("flushes the Sentry client", async () => { - const client = new SentryClient({ release: "cli@1.2.3" }); + const client = createSentryClient(); await client.flush(); diff --git a/packages/cli/cli/src/telemetry/automationTelemetryContext.ts b/packages/cli/cli/src/telemetry/automationTelemetryContext.ts new file mode 100644 index 000000000000..165aca41813e --- /dev/null +++ b/packages/cli/cli/src/telemetry/automationTelemetryContext.ts @@ -0,0 +1,56 @@ +/** + * Reads observability-relevant context from the environment for automation + * runs. Single source of truth for the enrichment fields that ride along on + * every PostHog event and every automation event API payload during an + * automation run. Sentry tags receive the same low-cardinality subset, with + * URLs intentionally kept out of tags. + */ + +import { getRunIdProperties } from "@fern-api/cli-telemetry"; + +/** + * Strict typed automation context. Matches the actions wrapper's + * run-level context shape: event-specific fields live on + * `AutomationTelemetryEvent`. + */ +export interface AutomationTelemetryContext { + action: string | undefined; + run_id: string | undefined; + github_run_id: string | undefined; + github_run_url: string | undefined; + org: string | undefined; + config_repo: string | undefined; + config_commit_sha: string | undefined; + config_branch: string | undefined; + config_pr_number: string | undefined; + trigger: string | undefined; + cli_version: string | undefined; +} + +/** + * Returns the automation context derived from the current process + * environment. + */ +export function getAutomationContextFromEnv(): AutomationTelemetryContext { + const { fern_run_id, github_run_id } = getRunIdProperties(); + return { + action: process.env.FERN_ACTION, + run_id: fern_run_id, + github_run_id: github_run_id, + github_run_url: process.env.FERN_GITHUB_RUN_URL, + org: undefined, + config_repo: process.env.FERN_CONFIG_REPO, + config_commit_sha: process.env.FERN_CONFIG_COMMIT_SHA, + config_branch: process.env.FERN_CONFIG_BRANCH, + config_pr_number: process.env.FERN_CONFIG_PR_NUMBER, + trigger: process.env.GITHUB_EVENT_NAME, + cli_version: process.env.CLI_VERSION + }; +} + +/** + * Returns true if automation mode was explicitly exported by the actions wrapper. + */ +export function isAutomationMode(): boolean { + return process.env.FERN_AUTOMATION === "true"; +} diff --git a/packages/cli/cli/src/telemetry/automationTelemetryEvent.ts b/packages/cli/cli/src/telemetry/automationTelemetryEvent.ts new file mode 100644 index 000000000000..d4592a915af7 --- /dev/null +++ b/packages/cli/cli/src/telemetry/automationTelemetryEvent.ts @@ -0,0 +1,168 @@ +/** + * Event-name and property conventions for CLI automation telemetry. The shape + * mirrors the actions wrapper: run-level fields live on + * `AutomationTelemetryContext`, while `AutomationTelemetryEvent` carries only + * event-specific fields. + */ +import type { CliError } from "@fern-api/task-context"; +import type { AutomationTelemetryContext } from "./automationTelemetryContext.js"; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +/** + * Canonical automation event names. Used as both the PostHog event name and + * the `event:` Sentry tag for grouping/filtering. + */ +export const AUTOMATION_EVENT_NAMES = { + GENERATION_STARTED: "generation_started", + GENERATION_COMPLETED: "generation_completed", + GENERATION_FAILED: "generation_failed", + VERIFICATION_FAILED: "verification_failed", + SDK_PR_CREATED: "sdk_pr_created", + UPGRADE_APPLIED: "upgrade_applied", + MAJOR_VERSION_BUMP: "major_version_bump" +} as const; + +export type AutomationEventName = (typeof AUTOMATION_EVENT_NAMES)[keyof typeof AUTOMATION_EVENT_NAMES]; + +/** + * Whether each automation event represents a failure. Used by the automation + * event API POST to gate Slack delivery (only failures need Slack). + */ +const AUTOMATION_EVENT_FAILURE: Record = { + [AUTOMATION_EVENT_NAMES.GENERATION_STARTED]: false, + [AUTOMATION_EVENT_NAMES.GENERATION_COMPLETED]: false, + [AUTOMATION_EVENT_NAMES.GENERATION_FAILED]: true, + [AUTOMATION_EVENT_NAMES.VERIFICATION_FAILED]: true, + [AUTOMATION_EVENT_NAMES.SDK_PR_CREATED]: false, + [AUTOMATION_EVENT_NAMES.UPGRADE_APPLIED]: false, + [AUTOMATION_EVENT_NAMES.MAJOR_VERSION_BUMP]: false +}; + +export function isFailureAutomationEventName(event: AutomationEventName): boolean { + return AUTOMATION_EVENT_FAILURE[event]; +} + +/** + * Structured telemetry event. Carries only fields specific to the event + * itself. Automation context is passed alongside every emit and flattened by + * each sink. + */ +export interface AutomationTelemetryEvent { + event: AutomationEventName; + errorCode?: CliError.Code; + attributes?: Record; +} + +/** + * Returns the PostHog property bag for an automation event. Attributes are + * flattened side-by-side with context fields so filters do not need nested + * property syntax. + */ +export function toPosthogProperties( + event: AutomationTelemetryEvent, + context: AutomationTelemetryContext +): Record { + return { + automation_mode: true, + surface: "cli", + action: context.action, + run_id: context.run_id, + github_run_id: context.github_run_id, + github_run_url: context.github_run_url, + org: context.org, + config_repo: context.config_repo, + config_commit_sha: context.config_commit_sha, + config_branch: context.config_branch, + config_pr_number: context.config_pr_number, + trigger: context.trigger, + cli_version: context.cli_version, + ...(event.errorCode !== undefined ? { error_code: event.errorCode } : {}), + ...(event.attributes ?? {}) + }; +} + +export function toSentryTags( + event: AutomationTelemetryEvent, + context: AutomationTelemetryContext +): Record { + function appendStringIfPresent(target: Record, key: string, value: string | undefined): void { + if (value != null) { + target[key] = value; + } + } + + const tags: Record = { + surface: "cli", + automation_mode: "true" + }; + appendStringIfPresent(tags, "event", event.event); + appendStringIfPresent(tags, "action", context.action); + appendStringIfPresent(tags, "run_id", context.run_id); + appendStringIfPresent(tags, "org", context.org); + appendStringIfPresent(tags, "config_repo", context.config_repo); + appendStringIfPresent(tags, "trigger", context.trigger); + appendStringIfPresent(tags, "error_code", event.errorCode ?? "none"); + return tags; +} + +export function toSentryContext( + event: AutomationTelemetryEvent, + context: AutomationTelemetryContext +): Record { + return { + github_run_id: context.github_run_id, + github_run_url: context.github_run_url, + config_commit_sha: context.config_commit_sha, + config_branch: context.config_branch, + config_pr_number: context.config_pr_number, + cli_version: context.cli_version, + ...(event.attributes ?? {}) + }; +} + +/** + * Returns the body sent to the automation event API at `/v1/automation/events`. + */ +export function toAutomationEventApiBody( + event: AutomationTelemetryEvent, + context: AutomationTelemetryContext +): Record { + return { + event: event.event, + timestamp: new Date().toISOString(), + surface: "cli", + action: context.action, + run_id: context.run_id, + github_run_id: context.github_run_id, + github_run_url: context.github_run_url, + org: context.org, + config_repo: context.config_repo, + config_commit_sha: context.config_commit_sha, + config_branch: context.config_branch, + config_pr_number: context.config_pr_number, + trigger: context.trigger, + cli_version: context.cli_version, + ...(event.errorCode !== undefined ? { error_code: event.errorCode } : {}), + ...(event.attributes ?? {}) + }; +} + +/** + * Maps argv to the failure event name emitted by top-level automation error + * handling. This is temporary until we have a better way to determine the + * command that failed. + */ +export function failureEventNameForCommand(argv: readonly string[]): AutomationEventName { + const joined = argv.join(" "); + if (/\bautomations\s+generate\b/.test(joined)) { + return AUTOMATION_EVENT_NAMES.GENERATION_FAILED; + } + if (/\bautomations\s+preview\b/.test(joined)) { + return AUTOMATION_EVENT_NAMES.VERIFICATION_FAILED; + } + // Default for any other automation path - leg 3 still routes a Slack + // message via the automation event API, so a sensible fallback matters. + return AUTOMATION_EVENT_NAMES.GENERATION_FAILED; +} diff --git a/packages/cli/cli/src/telemetry/processHandlers.ts b/packages/cli/cli/src/telemetry/processHandlers.ts new file mode 100644 index 000000000000..52106a0932ed --- /dev/null +++ b/packages/cli/cli/src/telemetry/processHandlers.ts @@ -0,0 +1,62 @@ +/** + * Process-level safety net for packaged CLI runs. + * + * The normal failure path goes through `cliContext.failWithoutThrowing` -> + * `reportError`, which fires the relevant telemetry flow. Anything that + * escapes that flow - uncaught exceptions and unhandled promise rejections - + * would otherwise exit without flushing Sentry/PostHog, and in automation + * mode without reaching Slack or the automation dashboards. + * + * Signal-driven cancellation (SIGINT / SIGTERM) is handled separately by the + * CLI entrypoint and, for automation commands, by command-specific cleanup. + * We deliberately do not duplicate signal coverage here to avoid double- + * emitting completion events. + * + * Handlers are not installed for local/dev runs so Node's default crash + * behavior stays loud while developing. A one-shot re-entry guard prevents an + * emit-during-shutdown loop if the report path itself throws. + */ +import { CliError } from "@fern-api/task-context"; +import type { CliContext } from "../cli-context/CliContext.js"; +import { shouldInitializeTelemetry } from "./shouldInitializeTelemetry.js"; + +export function installProcessHandlers(cliContext: CliContext, { isLocal }: { isLocal: boolean }): void { + if ( + !shouldInitializeTelemetry({ + cliName: cliContext.environment.cliName, + packageVersion: cliContext.environment.packageVersion, + isLocal + }) + ) { + return; + } + + let isHandlingFatal = false; + + const handleFatal = async (label: string, error: unknown): Promise => { + if (isHandlingFatal) { + // Re-entry: a second fatal landed while we were trying to flush + // the first. Bail out hard so we don't deadlock the runner. + process.exit(1); + } + isHandlingFatal = true; + try { + cliContext.failWithoutThrowing(label, error, { code: CliError.Code.InternalError }); + } catch { + // Reporting itself failed; nothing we can do, fall through to exit. + } + try { + await cliContext.exit({ code: 1 }); + } catch { + process.exit(1); + } + }; + + process.on("uncaughtException", (error) => { + void handleFatal("uncaught exception", error); + }); + + process.on("unhandledRejection", (reason) => { + void handleFatal("unhandled promise rejection", reason); + }); +} diff --git a/packages/cli/cli/src/telemetry/reportError.ts b/packages/cli/cli/src/telemetry/reportError.ts index 5912bf3bc62a..3ec584646c6b 100644 --- a/packages/cli/cli/src/telemetry/reportError.ts +++ b/packages/cli/cli/src/telemetry/reportError.ts @@ -1,28 +1,77 @@ -import type { PosthogEvent } from "@fern-api/task-context"; +import type { CaptureExceptionOptions, PosthogEvent } from "@fern-api/task-context"; import { CliError, resolveErrorCode, shouldReportToSentry, TaskAbortSignal } from "@fern-api/task-context"; +import type { AutomationTelemetryEmitOptions } from "./AutomationTelemetryManager.js"; +import { isAutomationMode } from "./automationTelemetryContext.js"; +import { type AutomationTelemetryEvent, failureEventNameForCommand } from "./automationTelemetryEvent.js"; export interface ErrorReporter { instrumentPostHogEvent: (event: PosthogEvent) => void; - captureException: (error: unknown, code?: CliError.Code) => void; + captureException: (error: unknown, options?: CaptureExceptionOptions) => string | undefined; + emitAutomationTelemetryEvent: (event: AutomationTelemetryEvent, options?: AutomationTelemetryEmitOptions) => void; } export function reportError( reporter: ErrorReporter, error: unknown, - options?: { message?: string; code?: CliError.Code } + options?: { message?: string; code?: CliError.Code; argv?: readonly string[] } ): void { if (error instanceof TaskAbortSignal) { return; } const code = resolveErrorCode(error, options?.code); + const argv = options?.argv ?? process.argv; + const command = argv.join(" "); + const errorMessage = options?.message ?? (error instanceof Error ? error.message : undefined) ?? ""; + const reportedError: unknown = error ?? new CliError({ message: errorMessage, code }); + + // Automations have a different error reporting flow than the CLI. + if (isAutomationMode()) { + reportErrorForAutomationMode(argv, reporter, code, errorMessage, reportedError); + } else { + reportErrorForNormalMode(reporter, command, code, reportedError); + } +} +function reportErrorForNormalMode( + reporter: ErrorReporter, + command: string, + code: CliError.Code, + reportedError: unknown +) { reporter.instrumentPostHogEvent({ - command: process.argv.join(" "), + command, properties: { failed: true, errorCode: code } }); + if (shouldReportToSentry(code)) { - reporter.captureException(error ?? new CliError({ message: options?.message ?? "", code }), code); + reporter.captureException(reportedError, { + tags: { + "error.code": code + } + }); } } + +function reportErrorForAutomationMode( + argv: readonly string[], + reporter: ErrorReporter, + code: CliError.Code, + errorMessage: string, + reportedError: unknown +) { + const failureEventName = failureEventNameForCommand(argv); + reporter.emitAutomationTelemetryEvent( + { + event: failureEventName, + errorCode: code, + attributes: { + error_message: errorMessage + } + }, + { + error: reportedError + } + ); +} diff --git a/packages/cli/cli/src/telemetry/shouldInitializeTelemetry.ts b/packages/cli/cli/src/telemetry/shouldInitializeTelemetry.ts new file mode 100644 index 000000000000..f308e762b5e6 --- /dev/null +++ b/packages/cli/cli/src/telemetry/shouldInitializeTelemetry.ts @@ -0,0 +1,21 @@ +import { isTelemetryDisabled } from "./isTelemetryDisabled.js"; + +export interface TelemetryInitializationOptions { + cliName: string; + packageVersion: string; + isLocal: boolean; +} + +export function shouldInitializeTelemetry({ + cliName, + packageVersion, + isLocal +}: TelemetryInitializationOptions): boolean { + if (isTelemetryDisabled() || isLocal) { + return false; + } + + const isLocalDev = packageVersion === "0.0.0"; + const isProductionCli = cliName === "fern" && process.env.SENTRY_ENVIRONMENT === "production"; + return !isLocalDev && isProductionCli; +} diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index 5052025fbded..6f3bd9a07a98 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,22 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.27.1 + changelogEntry: + - summary: | + Report escaped fatal CLI errors through telemetry in packaged production runs. + type: fix + createdAt: "2026-05-16" + irVersion: 66 +- version: 5.27.0 + changelogEntry: + - summary: | + Enrich CLI telemetry with automation-mode context (config repo, branch, + commit sha, PR number, trigger, and GitHub run details) on every PostHog + event and Sentry error report. + Add the three-leg failure flow (Sentry + PostHog + automation event API) + so automation-run failures surface end-to-end. + type: internal + createdAt: "2026-05-16" + irVersion: 66 - version: 5.26.5 changelogEntry: - summary: | diff --git a/packages/cli/posthog-manager/src/AccessTokenPosthogManager.ts b/packages/cli/posthog-manager/src/AccessTokenPosthogManager.ts index 47e017e6ca2e..55ea076f38b0 100644 --- a/packages/cli/posthog-manager/src/AccessTokenPosthogManager.ts +++ b/packages/cli/posthog-manager/src/AccessTokenPosthogManager.ts @@ -1,5 +1,5 @@ import { getRunIdProperties } from "@fern-api/cli-telemetry"; -import { PosthogEvent } from "@fern-api/task-context"; +import type { PosthogAutomationEvent, PosthogEvent } from "@fern-api/task-context"; import { PostHog } from "posthog-node"; import { PosthogManager } from "./PosthogManager.js"; @@ -31,6 +31,14 @@ export class AccessTokenPosthogManager implements PosthogManager { } } + public sendAutomationEvent(event: PosthogAutomationEvent): void { + this.posthog.capture({ + distinctId: event.distinctId, + event: event.event, + properties: event.properties + }); + } + public async flush(): Promise { try { await Promise.race([this.posthog.flush(), new Promise((resolve) => setTimeout(resolve, 3000))]); diff --git a/packages/cli/posthog-manager/src/NoopPosthogManager.ts b/packages/cli/posthog-manager/src/NoopPosthogManager.ts index 18a85859da95..d4d603fad1eb 100644 --- a/packages/cli/posthog-manager/src/NoopPosthogManager.ts +++ b/packages/cli/posthog-manager/src/NoopPosthogManager.ts @@ -4,6 +4,11 @@ export class NoopPosthogManager implements PosthogManager { async sendEvent(): Promise { // no-op } + + async sendAutomationEvent(): Promise { + // no-op + } + async identify(): Promise { // no-op } diff --git a/packages/cli/posthog-manager/src/PosthogManager.ts b/packages/cli/posthog-manager/src/PosthogManager.ts index 52dc34d2f6ad..92be856fe446 100644 --- a/packages/cli/posthog-manager/src/PosthogManager.ts +++ b/packages/cli/posthog-manager/src/PosthogManager.ts @@ -1,7 +1,8 @@ -import { PosthogEvent } from "@fern-api/task-context"; +import type { PosthogAutomationEvent, PosthogEvent } from "@fern-api/task-context"; export interface PosthogManager { sendEvent(event: PosthogEvent): void; + sendAutomationEvent(event: PosthogAutomationEvent): void; identify(): void; flush(): Promise; } diff --git a/packages/cli/posthog-manager/src/UserPosthogManager.ts b/packages/cli/posthog-manager/src/UserPosthogManager.ts index a85e13da2d63..7f996caf35ae 100644 --- a/packages/cli/posthog-manager/src/UserPosthogManager.ts +++ b/packages/cli/posthog-manager/src/UserPosthogManager.ts @@ -2,7 +2,7 @@ import { FernUserToken, getUserIdFromToken } from "@fern-api/auth"; import { getRunIdProperties } from "@fern-api/cli-telemetry"; import { createVenusService } from "@fern-api/core"; import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; -import { PosthogEvent } from "@fern-api/task-context"; +import type { PosthogAutomationEvent, PosthogEvent } from "@fern-api/task-context"; import { mkdir, readFile, writeFile } from "fs/promises"; import { homedir } from "os"; import { dirname } from "path"; @@ -50,6 +50,14 @@ export class UserPosthogManager implements PosthogManager { }); } + public sendAutomationEvent(event: PosthogAutomationEvent): void { + this.posthog.capture({ + distinctId: event.distinctId, + event: event.event, + properties: event.properties + }); + } + public async flush(): Promise { try { await Promise.race([this.posthog.flush(), new Promise((resolve) => setTimeout(resolve, 3000))]); diff --git a/packages/cli/task-context/src/MockTaskContext.ts b/packages/cli/task-context/src/MockTaskContext.ts index e9410a83edab..3627c7e820dc 100644 --- a/packages/cli/task-context/src/MockTaskContext.ts +++ b/packages/cli/task-context/src/MockTaskContext.ts @@ -28,6 +28,7 @@ export function createMockTaskContext({ logger = CONSOLE_LOGGER }: { logger?: Lo }, captureException: () => { // no-op in mock context + return undefined; }, getResult: () => TaskResult.Success, getLastFailureMessage: () => undefined, diff --git a/packages/cli/task-context/src/TaskContext.ts b/packages/cli/task-context/src/TaskContext.ts index 4866611a3e2d..850c5e5d5d38 100644 --- a/packages/cli/task-context/src/TaskContext.ts +++ b/packages/cli/task-context/src/TaskContext.ts @@ -2,12 +2,17 @@ import { Logger } from "@fern-api/logger"; import { type CliError } from "./CliError.js"; +export interface CaptureExceptionOptions { + tags?: Record; + context?: Record | undefined>; +} + export interface TaskContext { logger: Logger; takeOverTerminal: (run: () => void | Promise) => Promise; failAndThrow: (message?: string, error?: unknown, options?: { code?: CliError.Code }) => never; failWithoutThrowing: (message?: string, error?: unknown, options?: { code?: CliError.Code }) => void; - captureException: (error: unknown, code?: CliError.Code) => void; + captureException: (error: unknown, options?: CaptureExceptionOptions) => string | undefined; getResult: () => TaskResult; /** * Returns the most recent message passed to `failAndThrow` / `failWithoutThrowing`, @@ -38,6 +43,12 @@ export interface PosthogEvent { properties?: Record; } +export interface PosthogAutomationEvent { + distinctId: string | undefined; + event: string; + properties?: Record; +} + // TODO change to boolean representation export enum TaskResult { Success, diff --git a/packages/cli/task-context/src/index.ts b/packages/cli/task-context/src/index.ts index 5b624f867c47..74a49dc3b53b 100644 --- a/packages/cli/task-context/src/index.ts +++ b/packages/cli/task-context/src/index.ts @@ -2,9 +2,11 @@ export { CliError, resolveErrorCode, shouldReportToSentry } from "./CliError.js" export { createMockTaskContext } from "./MockTaskContext.js"; export { TaskAbortSignal } from "./TaskAbortSignal.js"; export { + type CaptureExceptionOptions, type CreateInteractiveTaskParams, type Finishable, type InteractiveTaskContext, + type PosthogAutomationEvent, type PosthogEvent, type Startable, type TaskContext, diff --git a/packages/cli/workspace/lazy-fern-workspace/src/__test__/helpers/createMockTaskContext.ts b/packages/cli/workspace/lazy-fern-workspace/src/__test__/helpers/createMockTaskContext.ts index c33c50c8338d..96fc13448b04 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/__test__/helpers/createMockTaskContext.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/__test__/helpers/createMockTaskContext.ts @@ -20,7 +20,7 @@ export function createMockTaskContext(): TaskContext { throw new Error(message ?? "Task failed"); }, failWithoutThrowing: noop, - captureException: noop, + captureException: () => undefined, getResult: () => TaskResult.Success, getLastFailureMessage: () => undefined, addInteractiveTask: () => { diff --git a/packages/cli/workspace/lazy-fern-workspace/src/__test__/resolveDescriptionMarkdownRefs.test.ts b/packages/cli/workspace/lazy-fern-workspace/src/__test__/resolveDescriptionMarkdownRefs.test.ts index 3e475526f901..73272d187913 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/__test__/resolveDescriptionMarkdownRefs.test.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/__test__/resolveDescriptionMarkdownRefs.test.ts @@ -25,7 +25,7 @@ function createContextWithWarn(): { context: TaskContext; warn: ReturnType undefined, getResult: () => TaskResult.Success, getLastFailureMessage: () => undefined, addInteractiveTask: () => { diff --git a/packages/seed/src/TaskContextImpl.ts b/packages/seed/src/TaskContextImpl.ts index f45efb610fcf..ab6f6cc7b8e6 100644 --- a/packages/seed/src/TaskContextImpl.ts +++ b/packages/seed/src/TaskContextImpl.ts @@ -3,6 +3,7 @@ import { addPrefixToString } from "@fern-api/core-utils"; import { createLogger, LogLevel } from "@fern-api/logger"; import { + type CaptureExceptionOptions, CliError, CreateInteractiveTaskParams, Finishable, @@ -101,8 +102,9 @@ export class TaskContextImpl implements Startable, Finishable, Task return this.lastFailureMessage; } - public captureException(_error: unknown, _code?: CliError.Code): void { + public captureException(_error: unknown, _options?: CaptureExceptionOptions): string | undefined { // no-op in seed context + return undefined; } public getResult(): TaskResult { diff --git a/packages/snippets/core/src/utils/createTaskContext.ts b/packages/snippets/core/src/utils/createTaskContext.ts index b953b183ae52..c8f256e30e3c 100644 --- a/packages/snippets/core/src/utils/createTaskContext.ts +++ b/packages/snippets/core/src/utils/createTaskContext.ts @@ -21,6 +21,7 @@ export function createTaskContext(): TaskContext { }, captureException: (_error: unknown) => { // no-op + return undefined; }, getResult: () => TaskResult.Success, getLastFailureMessage: () => undefined, diff --git a/seed/python-sdk/exhaustive/deps_with_min_python_version/poetry.lock b/seed/python-sdk/exhaustive/deps_with_min_python_version/poetry.lock index 66d2279dae4b..bd4f4dcab85c 100644 --- a/seed/python-sdk/exhaustive/deps_with_min_python_version/poetry.lock +++ b/seed/python-sdk/exhaustive/deps_with_min_python_version/poetry.lock @@ -1076,14 +1076,14 @@ orjson = ">=3.11.5" [[package]] name = "langsmith" -version = "0.8.4" +version = "0.8.5" description = "Client library to connect to the LangSmith Observability and Evaluation Platform." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "langsmith-0.8.4-py3-none-any.whl", hash = "sha256:4e334ab223d10129c9943c461d95fa9089523638ea29cd048045a7f99b973f50"}, - {file = "langsmith-0.8.4.tar.gz", hash = "sha256:989b387f6ff92ec5f9d14c0edb333e2579590cad5a1ca07042d924b0ec43cd10"}, + {file = "langsmith-0.8.5-py3-none-any.whl", hash = "sha256:efc779f9d450dcaf9d97bc8894f4926276509d6e730e05289af9a64debce06ae"}, + {file = "langsmith-0.8.5.tar.gz", hash = "sha256:3615243d99c12f4047f13042bdc05a373dce232d106a6511b3ca7b48c5af1c2c"}, ] [package.dependencies] @@ -1336,14 +1336,14 @@ files = [ [[package]] name = "openai" -version = "2.36.0" +version = "2.37.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63"}, - {file = "openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7"}, + {file = "openai-2.37.0-py3-none-any.whl", hash = "sha256:814633888b8f3b1ffd6615697c6e4ef93632d08b7c2e28c8c5ef3556e5a10107"}, + {file = "openai-2.37.0.tar.gz", hash = "sha256:f4bc562cc5f3a43d40d678105572d9d44765f6e0f50c125f63055419b72f4bd9"}, ] [package.dependencies]