Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/trueforge-sentry-p1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@truefoundry/trueforge": minor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"@truefoundry/trueforge": minor
"@truefoundry/trueforge": patch

"@truefoundry/trueforge-core": patch
---

Add Sentry for P1 critical flows: TrueFoundry auth-server or SENTRY_DSN init, and agent-team captures for controller, dual-write, SFY hard failures, missing external_id, and sandbox init.
4 changes: 4 additions & 0 deletions packages/trueforge-core/src/core/sandbox/Sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export interface SandboxOptions {
mcpConnectTimeoutMs: number;
tracing: AgentTracing;
logger: Logger;
onInitFailure?: ((error: unknown) => void) | undefined;
}

export const SANDBOX_EXEC_TOOL_NAME = 'exec';
Expand Down Expand Up @@ -210,6 +211,7 @@ export class Sandbox extends LocalToolMCP {
private readonly logger: Logger;
// Pre-resolved credential-store file content (null = clear / no git auth).
private readonly resolvedGitCredentialsContent: string | null;
private readonly onInitFailure: ((error: unknown) => void) | undefined;
private codeModeDispatcher: CodeModeDispatcher | undefined;
private codeModeTransport: CodeModeTransport | undefined;
/** Cached from transport.getClientInstall after sandbox init (when Code Mode is configured). */
Expand All @@ -234,6 +236,7 @@ export class Sandbox extends LocalToolMCP {
this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1000) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS;
this.logger = options.logger.child({ module: 'Sandbox' });
this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null;
this.onInitFailure = options.onInitFailure;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to take this as option ? sandbox method caller can alert if needed


if (this.existingSandboxId) {
this.existingSandboxInfo = { sandbox_id: this.existingSandboxId };
Expand Down Expand Up @@ -525,6 +528,7 @@ export class Sandbox extends LocalToolMCP {
({ sandboxInfo, sandboxCreated } = await this.ensureReadySandbox());
} catch (e) {
this.logger.error('Sandbox initialization failed', extractErrorLogFields(e));
this.onInitFailure?.(e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sandbox init misses some failures

Medium Severity

onInitFailure runs only in handleExec's first ensureReadySandbox catch. Init failures from uploadFile / uploadUserFile, and from the later recreate-and-ensureSandboxInitialized path after SandboxNotAvailableError, never invoke the callback, so those P1 sandbox init errors are not sent to Sentry.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7dd42b6. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you fix this

const message = e instanceof Error ? e.message : 'Sandbox initialization failed';
const fallback = this.existingSandboxInfo;
return toolResultResponse({
Expand Down
9 changes: 9 additions & 0 deletions packages/trueforge/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ PORT=8790
# TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS=10000
## Max ms for ServiceFoundry agent create/update/delete calls. Default 3000.
# TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS=3000
## Auth server for Sentry DSN lookup when SENTRY_ENABLED=true in TrueFoundry mode.
# TRUEFOUNDRY_AUTH_SERVER_URL=https://auth.truefoundry.com

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# TRUEFOUNDRY_AUTH_SERVER_URL=https://auth.truefoundry.com
# TRUEFOUNDRY_AUTH_SERVER_URL=

## Optional tenantName query param for the auth-server Sentry DSN lookup.
# TRUEFOUNDRY_TENANT_NAME=

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this? we have tenant name env var already, can reuse?


## Sentry error reporting (off by default). No-op when NODE_ENV is development/test/local.
# SENTRY_ENABLED=false
## Required when SENTRY_ENABLED=true and not in TrueFoundry mode.
# SENTRY_DSN=

## Mutual TLS for this process's HTTPS listener and controller→server. Off by default (plain HTTP).
## When true, serves HTTPS with client-cert enforcement (except /healthz) and the controller
Expand Down
1 change: 1 addition & 0 deletions packages/trueforge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@hono/swagger-ui": "^0.2.2",
"@hono/zod-openapi": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@sentry/node": "^10.74.0",
"@truefoundry/trueforge-core": "workspace:*",
"@truefoundry/trueforge-sdk": "workspace:*",
"better-sqlite3": "^13.0.3",
Expand Down
12 changes: 12 additions & 0 deletions packages/trueforge/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,8 @@ export interface SharedServerConfiguration {
* `TRUEFORGE_MTLS_ENABLED` is true. Env: `TRUEFORGE_MTLS_CERTS_DIR`. Default `/etc/tls`.
*/
TRUEFORGE_MTLS_CERTS_DIR: string;
SENTRY_ENABLED: boolean;
SENTRY_DSN: string | undefined;
}

export type StandaloneServerConfiguration = SharedServerConfiguration & {
Expand Down Expand Up @@ -654,6 +656,8 @@ export type DistributedServerConfiguration = SharedServerConfiguration & {
* Env: `TRUEFOUNDRY_SANDBOX_SETTINGS`.
*/
TRUEFOUNDRY_SANDBOX_SETTINGS: string | undefined;
TRUEFOUNDRY_AUTH_SERVER_URL: string | undefined;
TRUEFOUNDRY_TENANT_NAME: string | undefined;
};

export type ServerConfiguration = StandaloneServerConfiguration | DistributedServerConfiguration;
Expand Down Expand Up @@ -768,6 +772,12 @@ const shared: SharedServerConfiguration = {
defaultValue: false,
}),
TRUEFORGE_MTLS_CERTS_DIR: getEnv('TRUEFORGE_MTLS_CERTS_DIR', { defaultValue: '/etc/tls' }) ?? '/etc/tls',
SENTRY_ENABLED: parseBoolean({
envKey: 'SENTRY_ENABLED',
raw: getEnv('SENTRY_ENABLED'),
defaultValue: false,
}),
SENTRY_DSN: getEnv('SENTRY_DSN', { required: false }),
};

const configuration: ServerConfiguration = standalone
Expand Down Expand Up @@ -829,6 +839,8 @@ const configuration: ServerConfiguration = standalone
TRUEFOUNDRY_SANDBOX_API_KEY: getEnv('TRUEFOUNDRY_SANDBOX_API_KEY', { required: false }),
TRUEFOUNDRY_SANDBOX_SERVER_URL: getEnv('TRUEFOUNDRY_SANDBOX_SERVER_URL', { required: false }),
TRUEFOUNDRY_SANDBOX_SETTINGS: getEnv('TRUEFOUNDRY_SANDBOX_SETTINGS', { required: false }),
TRUEFOUNDRY_AUTH_SERVER_URL: getEnv('TRUEFOUNDRY_AUTH_SERVER_URL', { required: false }),
TRUEFOUNDRY_TENANT_NAME: getEnv('TRUEFOUNDRY_TENANT_NAME', { required: false }),
};

export function isOidcConfigured(
Expand Down
6 changes: 5 additions & 1 deletion packages/trueforge/src/controller-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createDb } from './db/postgres/client';
import { PostgresScheduleStore } from './db/postgres/schedule-store/PostgresScheduleStore';
import { createControllerLogger } from './logger';
import { PACKAGE_VERSION } from './packageVersion';
import { captureCriticalException, exitAfterFlushSentry, initSentry } from './sentry';

try {
const logger = createControllerLogger({
Expand All @@ -26,6 +27,8 @@ try {
version: PACKAGE_VERSION,
});

await initSentry(configuration, logger, { tags: { component: 'controller' } });

if (configuration.STANDALONE) {
// Not an error: in standalone the server process owns the controller in-process, so a
// dedicated controller has nothing to do. Exit cleanly (e.g. `pnpm standalone:dev` also
Expand Down Expand Up @@ -55,5 +58,6 @@ try {
});
} catch (error) {
console.error('Failed to start controller:', error instanceof Error ? error.message : error);
process.exit(1);
captureCriticalException(error, { tags: { module: 'controller', operation: 'boot' } });
await exitAfterFlushSentry(1);
Comment on lines +61 to +62

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this also can spam if pod keeps restarting

}
7 changes: 6 additions & 1 deletion packages/trueforge/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Controller } from './controller/Controller';
import { scheduleDispatchLoop } from './controller/scheduleDispatch';
import type { IScheduleStore } from './db/scheduleStore';
import type { WithTransaction } from './db/transaction';
import { captureCriticalException, exitAfterFlushSentry } from './sentry';

/**
* Controller whose schedule loop hands runs to the server over HTTP
Expand Down Expand Up @@ -52,7 +53,11 @@ export function runController<TTransaction>(params: {
// Passes only hold short transactions, so the deadline should never elapse.
setTimeout(() => {
logger.warn(`Controller drain timed out after ${String(gracefulTimeoutSeconds)}s, exiting`);
process.exit(1);
captureCriticalException(new Error('Controller drain timed out'), {
tags: { module: 'controller', operation: 'drain' },
extra: { gracefulTimeoutSeconds },
});
void exitAfterFlushSentry(1);
}, gracefulTimeoutSeconds * 1000).unref();

await controller.stop();
Expand Down
5 changes: 5 additions & 0 deletions packages/trueforge/src/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* process. Loops are written assuming this.
*/
import type { Logger } from 'winston';
import { captureCriticalException } from '../sentry';

/** Reason passed to {@link AbortController.abort} when {@link Controller.stop} runs. */
export const CONTROLLER_STOPPED = 'controller-stopped';
Expand Down Expand Up @@ -120,6 +121,10 @@ export class Controller {
return;
}
this.#logger.error('Control loop pass failed', { loop: loop.name, error });
captureCriticalException(error, {
tags: { module: 'controller', operation: 'tick' },
extra: { loop: loop.name },
});
Comment on lines +124 to +127

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure we don't end up spamming sentry and running out of quota
What is the probability of reaching here?

}
})();

Expand Down
9 changes: 9 additions & 0 deletions packages/trueforge/src/controller/scheduleDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { WithTransaction } from '../db/transaction';
import { createTlsFetch, normalizeTlsUrl } from '../http/tls';
import { nextTriggerAfter } from '../runtime/cron';
import { InvalidCronError, type ScheduleRunStatus } from '../schemas/schedule';
import { captureCriticalException } from '../sentry';
import type { ControlLoop } from './Controller';

/**
Expand Down Expand Up @@ -303,6 +304,10 @@ export async function dispatchScheduledRuns<TTransaction>(params: {
run_id: run.id,
error,
});
captureCriticalException(error, {
tags: { module: 'scheduleDispatch', operation: 'handoff' },
extra: { schedule_id: schedule.id, run_id: run.id },
});
await finishScheduledRun({
store,
run,
Expand All @@ -329,6 +334,10 @@ export async function dispatchScheduledRuns<TTransaction>(params: {
run_id: run.id,
error,
});
captureCriticalException(error, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we not doing the same at line 314. either lets capture all scenarios or none

tags: { module: 'scheduleDispatch', operation: 'processRun' },
extra: { schedule_id: run.schedule_id, run_id: run.id },
});
}
}

Expand Down
15 changes: 12 additions & 3 deletions packages/trueforge/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import type { IOAuthTokenStore } from './mcp/auth/types';
import { PACKAGE_VERSION } from './packageVersion';
import { ActiveTurnRegistry } from './runtime/activeTurns';
import { EventSubscriptionRegistry } from './runtime/event-subscription';
import { captureCriticalException, exitAfterFlushSentry, initSentry } from './sentry';
import { printStandaloneStartupBanner } from './startupBanner';
import {
parsePerServerMcpHeaders,
Expand Down Expand Up @@ -588,6 +589,8 @@ try {
version: PACKAGE_VERSION,
});

await initSentry(configuration, logger, { tags: { component: 'server' } });

if (configuration.STANDALONE) {
printStandaloneStartupBanner({ version: PACKAGE_VERSION, color: shouldColorize() });
await prepareCodeModeSocketParent({ path: configuration.CODE_MODE_SOCKET_PARENT, logger });
Expand Down Expand Up @@ -686,7 +689,8 @@ try {

server.on('error', (error: unknown) => {
console.error('Failed to start server:', error instanceof Error ? error.message : error);
process.exit(1);
captureCriticalException(error, { tags: { module: 'main', operation: 'listen' } });
void exitAfterFlushSentry(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this void

Comment on lines +692 to +693

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

});

// Graceful drain is the safe default for built and direct execution.
Expand All @@ -703,7 +707,11 @@ try {
// Arm at the start of each shutdown; unref so this timer alone cannot keep the process alive.
setTimeout(() => {
logger.warn(`Drain timed out after ${String(configuration.GRACEFUL_TIMEOUT_SECONDS)}s, exiting`);
process.exit(1);
captureCriticalException(new Error('Server drain timed out'), {
tags: { module: 'main', operation: 'drain' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does module show up on sentry? Will we be able to filter by service: TrueForge? or should that be the module?

extra: { gracefulTimeoutSeconds: configuration.GRACEFUL_TIMEOUT_SECONDS },
});
void exitAfterFlushSentry(1);
Comment on lines +710 to +714

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a warn even, do we really need sentry issue for this?

}, configuration.GRACEFUL_TIMEOUT_SECONDS * 1000).unref();

const closed = new Promise<void>(resolve => {
Expand Down Expand Up @@ -747,5 +755,6 @@ try {
}
} catch (error) {
console.error('Failed to start server:', error instanceof Error ? error.message : error);
process.exit(1);
captureCriticalException(error, { tags: { module: 'main', operation: 'startup' } });
await exitAfterFlushSentry(1);
Comment on lines +758 to +759

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can also spam because of crash loop

}
6 changes: 6 additions & 0 deletions packages/trueforge/src/runtime/sessionResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { LocalSandboxProvider } from '../sandbox/local/provider/LocalSandboxProv
import { getCachedLocalSandboxSupport, isLocalSandboxFallbackEnabled } from '../sandbox/localRuntime';
import { toSandboxProviderFromRecord } from '../sandbox/providerUtils';
import type { ReasoningEffort } from '../schemas/modelProvider';
import { captureCriticalException } from '../sentry';

export interface McpConnection {
url: string;
Expand Down Expand Up @@ -244,6 +245,11 @@ export function buildTurnSandbox(input: {
skillMounter: new SkillMounter({ skills: input.skills ?? [] }),
tracing: input.tracing,
logger: input.logger,
onInitFailure: error => {
captureCriticalException(error, {
tags: { module: 'sandbox', operation: 'init' },
});
},
});
}

Expand Down
23 changes: 23 additions & 0 deletions packages/trueforge/src/sentry/captureCriticalException.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as Sentry from '@sentry/node';
import { isTrueFoundryModeEnabled } from '../config';

export function captureCriticalException(
err: unknown,
options?: { tags?: Record<string, string>; extra?: Record<string, unknown> },
): void {
Sentry.withScope(scope => {
scope.setTags({
...(isTrueFoundryModeEnabled()
? {
priority: 'p1',
team: 'agent-team',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we cannot hard code these. Tags can be part of sentry config.

@chiragjn chiragjn Sep 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do SENTRY_ADDITIONAL_TAGS: Record<string, string>

}
: {}),
...options?.tags,
});
if (options?.extra) {
scope.setExtras(options.extra);
}
Sentry.captureException(err);
});
}
15 changes: 15 additions & 0 deletions packages/trueforge/src/sentry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as Sentry from '@sentry/node';

export { captureCriticalException } from './captureCriticalException';
export { initSentry, type InitSentryOptions } from './initSentry';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to init always? what if users don't want to enable sentry ?


export const SENTRY_FLUSH_TIMEOUT_MS = 2000;

export async function flushSentry(timeoutMs: number = SENTRY_FLUSH_TIMEOUT_MS): Promise<void> {
await Sentry.flush(timeoutMs);
}

export async function exitAfterFlushSentry(exitCode: number): Promise<never> {
await flushSentry();
process.exit(exitCode);
}
68 changes: 68 additions & 0 deletions packages/trueforge/src/sentry/initSentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as Sentry from '@sentry/node';
import type { Logger } from 'winston';

import { isTrueFoundryModeEnabled, type ServerConfiguration } from '../config';
import { PACKAGE_VERSION } from '../packageVersion';
import { initTrueFoundrySentry } from '../truefoundry/initTrueFoundrySentry';

function isLocalLikeEnv(nodeEnv: string | undefined): boolean {
return nodeEnv === 'development' || nodeEnv === 'test' || nodeEnv === 'local';
}

export interface InitSentryOptions {
tags?: Record<string, string>;
}

export async function initSentry(
config: ServerConfiguration,
logger: Pick<Logger, 'info' | 'error'>,
options?: InitSentryOptions,
): Promise<void> {
if (!config.SENTRY_ENABLED || isLocalLikeEnv(config.NODE_ENV)) {
logger.info('Sentry is not enabled (SENTRY_ENABLED=false or local-like NODE_ENV)');
return;
}

if (isTrueFoundryModeEnabled(config)) {
const authServerUrl = config.TRUEFOUNDRY_AUTH_SERVER_URL;
const apiKey = config.TRUEFOUNDRY_API_KEY;
if (authServerUrl === undefined || authServerUrl.trim() === '') {
logger.error('TRUEFOUNDRY_AUTH_SERVER_URL is required when SENTRY_ENABLED in TrueFoundry mode');
return;
}
if (apiKey === undefined || apiKey.trim() === '') {
logger.error('TRUEFOUNDRY_API_KEY is required when SENTRY_ENABLED in TrueFoundry mode');
return;
}
await initTrueFoundrySentry({
config: {
TRUEFOUNDRY_AUTH_SERVER_URL: authServerUrl,
TRUEFOUNDRY_API_KEY: apiKey,
TRUEFOUNDRY_TENANT_NAME: config.TRUEFOUNDRY_TENANT_NAME,
TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS: config.TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS,
},
logger,
version: PACKAGE_VERSION,
tags: options?.tags,
});
return;
}

const dsn = config.SENTRY_DSN;
if (dsn === undefined || dsn.trim() === '') {
logger.error('SENTRY_DSN is required when SENTRY_ENABLED outside TrueFoundry mode');
return;
}
Sentry.init({
dsn,
includeLocalVariables: false,
integrations: [],
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default Sentry integrations stay enabled

High Severity

integrations: [] does not turn off Sentry's default integrations. In @sentry/node v10 those still load unless defaultIntegrations is false, so enabling Sentry also installs process-wide onUncaughtException, onUnhandledRejection, and HTTP/fetch instrumentation. That reports more than the intended P1 captures and can hook http/undici used by the server.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7dd42b6. Configure here.

Sentry.getGlobalScope().setTag('TRUEFORGE_VERSION', PACKAGE_VERSION);
if (options?.tags) {
for (const [key, value] of Object.entries(options.tags)) {
Sentry.getGlobalScope().setTag(key, value);
}
}
logger.info('Sentry initialised');
}
Loading
Loading