-
Notifications
You must be signed in to change notification settings - Fork 462
feat: add sentry config #745
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@truefoundry/trueforge": minor | ||
| "@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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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). */ | ||
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }; | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sandbox init misses some failuresMedium Severity
Additional Locations (2)Reviewed by Cursor Bugbot for commit 7dd42b6. Configure here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| ## Optional tenantName query param for the auth-server Sentry DSN lookup. | ||||||
| # TRUEFOUNDRY_TENANT_NAME= | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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({ | ||
|
|
@@ -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 | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this also can spam if pod keeps restarting |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| })(); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
||
| /** | ||
|
|
@@ -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, | ||
|
|
@@ -329,6 +334,10 @@ export async function dispatchScheduledRuns<TTransaction>(params: { | |
| run_id: run.id, | ||
| error, | ||
| }); | ||
| captureCriticalException(error, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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 }); | ||
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this void
Comment on lines
+692
to
+693
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
|
@@ -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' }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 => { | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this can also spam because of crash loop |
||
| } | ||
| 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', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we cannot hard code these. Tags can be part of sentry config.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can do |
||
| } | ||
| : {}), | ||
| ...options?.tags, | ||
| }); | ||
| if (options?.extra) { | ||
| scope.setExtras(options.extra); | ||
| } | ||
| Sentry.captureException(err); | ||
| }); | ||
| } | ||
| 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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| 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: [], | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Default Sentry integrations stay enabledHigh Severity
Additional Locations (1)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'); | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.