Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions packages/cli/cli-v2/src/context/adapter/TaskContextAdapter.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/cli-v2/src/context/withContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
23 changes: 15 additions & 8 deletions packages/cli/cli-v2/src/telemetry/TelemetryClient.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/cli/changes/5.27.0/automation-mode-telemetry.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions packages/cli/cli/changes/5.27.1/fatal-error-telemetry.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- summary: |
Report escaped fatal CLI errors through telemetry in packaged production runs.
type: fix
46 changes: 38 additions & 8 deletions packages/cli/cli/src/cli-context/CliContext.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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<T>(run: (context: TaskContext) => T | Promise<T>): Promise<T> {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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)
};
}

Expand Down
33 changes: 27 additions & 6 deletions packages/cli/cli/src/cli-context/TaskContextImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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;
}
}

Expand All @@ -49,7 +56,11 @@ export class TaskContextImpl implements Startable<TaskContext>, 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,
Expand All @@ -58,7 +69,8 @@ export class TaskContextImpl implements Startable<TaskContext>, Finishable, Task
onResult,
shouldBufferLogs,
instrumentPostHogEvent,
captureException
captureException,
emitAutomationTelemetryEvent
}: TaskContextImpl.Init) {
this.logImmediately = logImmediately;
this.logPrefix = logPrefix ?? "";
Expand All @@ -68,6 +80,7 @@ export class TaskContextImpl implements Startable<TaskContext>, Finishable, Task
this.shouldBufferLogs = shouldBufferLogs;
this.instrumentPostHogEventImpl = instrumentPostHogEvent;
this.captureExceptionImpl = captureException;
this.emitAutomationTelemetryEventImpl = emitAutomationTelemetryEvent;
}

public start(): Finishable & TaskContext {
Expand Down Expand Up @@ -116,8 +129,15 @@ export class TaskContextImpl implements Startable<TaskContext>, 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 {
Expand Down Expand Up @@ -168,7 +188,8 @@ export class TaskContextImpl implements Startable<TaskContext>, 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand All @@ -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 };
}
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down
Loading
Loading