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

Add Sentry for critical-flow error reporting (TrueFoundry auth-server or SENTRY_DSN init) with configurable additional tags.
12 changes: 12 additions & 0 deletions packages/trueforge/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,18 @@ POSTGRES_PORT=5432
# 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=
## Optional tenantName query param for the auth-server Sentry DSN lookup.
# TRUEFOUNDRY_TENANT_NAME=

## 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=
## Extra tags applied on Sentry init (JSON object of strings), e.g. {"priority":"p1","team":"agent-team"}.
# SENTRY_ADDITIONAL_TAGS=

## Optional per-tenant allowlist of model provider account names (JSON object).
## Tenants omitted are unfiltered. Example: {"internal":["openai-main","anthropic-main"]}
# TRUEFOUNDRY_TENANT_ID_TO_ALLOWED_MODEL_PROVIDER_ACCOUNTS={"internal":["openai-main"]}
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
38 changes: 38 additions & 0 deletions packages/trueforge/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,30 @@ function parseTrueFoundrySandboxProvider(raw: string | undefined): 'daytona' | '
);
}

/** Parses `SENTRY_ADDITIONAL_TAGS` as a JSON object of string values. Unset/blank → `{}`. */
function parseSentryAdditionalTags(raw: string | undefined): Record<string, string> {
if (raw === undefined || raw.trim() === '') {
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error('SENTRY_ADDITIONAL_TAGS must be a JSON object of string values', { cause: error });
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('SENTRY_ADDITIONAL_TAGS must be a JSON object of string values');
}
const tags: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'string') {
throw new Error(`SENTRY_ADDITIONAL_TAGS.${key} must be a string`);
}
tags[key] = value;
}
return tags;
}

/** Parses `POSTGRES_SSL_MODE`. Unset/blank → `''`. Unknown values throw. */
function validatePostgresSslMode(raw: string | undefined): PostgresSslMode | '' {
const mode = raw?.trim() ?? '';
Expand Down Expand Up @@ -807,6 +831,9 @@ export interface SharedServerConfiguration {
OUTBOUND_URL_ALLOWED_HOSTS: string[];
/** Hosts always blocked. Env: `OUTBOUND_URL_BLOCKED_HOSTS` (JSON string array). Empty = none. */
OUTBOUND_URL_BLOCKED_HOSTS: string[];
SENTRY_ENABLED: boolean;
SENTRY_DSN: string | undefined;
SENTRY_ADDITIONAL_TAGS: Record<string, string>;
}

export type StandaloneServerConfiguration = SharedServerConfiguration & {
Expand Down Expand Up @@ -970,6 +997,8 @@ export type DistributedServerConfiguration = SharedServerConfiguration & {
* Unset / empty → web search tools are not registered. Env: `TRUEFOUNDRY_WEB_SEARCH_PROVIDER`.
*/
TRUEFOUNDRY_WEB_SEARCH_PROVIDER: TrueFoundryWebSearchProviderEnv | undefined;
TRUEFOUNDRY_AUTH_SERVER_URL: string | undefined;
TRUEFOUNDRY_TENANT_NAME: string | undefined;
};

export type ServerConfiguration = StandaloneServerConfiguration | DistributedServerConfiguration;
Expand Down Expand Up @@ -1103,6 +1132,13 @@ const shared: SharedServerConfiguration = {
envKey: 'OUTBOUND_URL_BLOCKED_HOSTS',
raw: getEnv('OUTBOUND_URL_BLOCKED_HOSTS'),
}),
SENTRY_ENABLED: parseBoolean({
envKey: 'SENTRY_ENABLED',
raw: getEnv('SENTRY_ENABLED'),
defaultValue: false,
}),
SENTRY_DSN: getEnv('SENTRY_DSN', { required: false }),
SENTRY_ADDITIONAL_TAGS: parseSentryAdditionalTags(getEnv('SENTRY_ADDITIONAL_TAGS', { required: false })),
};

const configuration: ServerConfiguration = standalone
Expand Down Expand Up @@ -1202,6 +1238,8 @@ const configuration: ServerConfiguration = standalone
TRUEFOUNDRY_WEB_SEARCH_PROVIDER: parseTrueFoundryWebSearchProvider(
getEnv('TRUEFOUNDRY_WEB_SEARCH_PROVIDER', { 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
3 changes: 3 additions & 0 deletions 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 { 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
17 changes: 17 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,14 @@ export async function dispatchScheduledRuns<TTransaction>(params: {
run_id: run.id,
error,
});
captureCriticalException(error, {
tags: { module: 'scheduleDispatch', operation: 'handoff' },
extra: {
tenant_id: run.tenant_id,
schedule_id: schedule.id,
run_id: run.id,
},
});
await finishScheduledRun({
store,
run,
Expand All @@ -329,6 +338,14 @@ export async function dispatchScheduledRuns<TTransaction>(params: {
run_id: run.id,
error,
});
captureCriticalException(error, {
Comment thread
thesujai marked this conversation as resolved.
tags: { module: 'scheduleDispatch', operation: 'processRun' },
extra: {
tenant_id: run.tenant_id,
schedule_id: run.schedule_id,
run_id: run.id,
},
});
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/trueforge/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { PACKAGE_VERSION } from './packageVersion';
import { ActiveTurnRegistry } from './runtime/activeTurns';
import { EventSubscriptionRegistry } from './runtime/event-subscription';
import type { ConnectedRedis } from './runtime/redis';
import { initSentry } from './sentry';
import { printStandaloneStartupBanner } from './startupBanner';
import { InlineMcpServerStore } from './truefoundry/InlineMcpServerStore';
import { parseInlineMcpServers, parseInlineSkills, X_TFG_MCP, X_TFG_SKILLS } from './truefoundry/inlineResources';
Expand Down Expand Up @@ -675,6 +676,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
16 changes: 16 additions & 0 deletions packages/trueforge/src/sentry/captureCriticalException.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Sentry from '@sentry/node';

export function captureCriticalException(
err: unknown,
options?: { tags?: Record<string, string>; extra?: Record<string, unknown> },
): void {
Sentry.withScope(scope => {
if (options?.tags) {
scope.setTags(options.tags);
}
if (options?.extra) {
scope.setExtras(options.extra);
}
Sentry.captureException(err);
});
}
2 changes: 2 additions & 0 deletions packages/trueforge/src/sentry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { captureCriticalException } from './captureCriticalException';
export { initSentry, type InitSentryOptions } from './initSentry';
Comment thread
thesujai marked this conversation as resolved.
58 changes: 58 additions & 0 deletions packages/trueforge/src/sentry/initSentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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';
}

function applyGlobalTags(tags: Record<string, string>): void {
const scope = Sentry.getGlobalScope();
for (const [key, value] of Object.entries(tags)) {
scope.setTag(key, value);
}
}

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;
}

const globalTags: Record<string, string> = {
service: 'trueforge',
TRUEFORGE_VERSION: PACKAGE_VERSION,
...config.SENTRY_ADDITIONAL_TAGS,
...options?.tags,
};

if (isTrueFoundryModeEnabled(config)) {
await initTrueFoundrySentry({ logger, tags: globalTags });
return;
}

const dsn = config.SENTRY_DSN;
if (dsn === undefined || dsn.trim() === '') {
logger.error('SENTRY_DSN is required when SENTRY_ENABLED=true');
return;
}
Sentry.init({
dsn,
includeLocalVariables: false,
defaultIntegrations: false,
integrations: [],
});
Comment thread
cursor[bot] marked this conversation as resolved.
applyGlobalTags(globalTags);
logger.info('Sentry initialised');
}
27 changes: 25 additions & 2 deletions packages/trueforge/src/truefoundry/TrueFoundryAgentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { PostgresAgentStore } from '../db/postgres/agent-store/PostgresAgentStore';
import type { Database } from '../db/postgres/types';
import { AGENT_DESCRIPTION_MAX_LENGTH } from '../schemas/agent';
import { captureCriticalException } from '../sentry';
import { callerAccessToken, type ResolveAccessToken } from './accessToken';
import {
TrueFoundryServiceFoundryServerClient,
Expand Down Expand Up @@ -174,7 +175,19 @@ export class TrueFoundryAgentStore implements IAgentStore<Transaction<Database>>
failures.push(asError(cleanupError));
}
if (failures.length > 1) {
throw new AggregateError(failures, 'createAgent failed and cleanup also failed', { cause: error });
const aggregate = new AggregateError(failures, 'createAgent failed and cleanup also failed', {
cause: error,
});
captureCriticalException(aggregate, {
tags: { module: 'TrueFoundryAgentStore', operation: 'dualWrite' },
extra: {
agent_id: created.id,
agent_name: created.name,
tenant_id: input.tenant_id,
external_id: externalId,
},
});
throw aggregate;
}
throw error;
}
Expand Down Expand Up @@ -226,11 +239,21 @@ export class TrueFoundryAgentStore implements IAgentStore<Transaction<Database>>
}),
});
} catch (restoreError) {
throw new AggregateError(
const aggregate = new AggregateError(
[asError(error), asError(restoreError)],
'updateAgent failed and ServiceFoundry restore also failed',
{ cause: restoreError },
);
captureCriticalException(aggregate, {
tags: { module: 'TrueFoundryAgentStore', operation: 'dualWrite' },
extra: {
agent_id: input.id,
agent_name: previous.name,
tenant_id: input.tenant_id,
external_id: previous.external_id,
},
});
throw aggregate;
}
throw error;
}
Expand Down
16 changes: 14 additions & 2 deletions packages/trueforge/src/truefoundry/errors.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { HTTPException } from 'hono/http-exception';
import type { AgentRecord } from '../db/agentStore';
import { captureCriticalException } from '../sentry';

export const AGENT_EXTERNAL_ID_REQUIRED = 'Agent is missing a TrueFoundry external id';
export const TRUEFOUNDRY_MANAGED_STATUS = 424 as const;
export const TRUEFOUNDRY_MANAGED_MESSAGE = 'This resource is managed by TrueFoundry';

/** Require the remote identity needed for TrueFoundry agent operations. */
export function requireTrueFoundryAgentExternalId(agent: Pick<AgentRecord, 'external_id'>): string {
export function requireTrueFoundryAgentExternalId(
agent: Pick<AgentRecord, 'id' | 'tenant_id' | 'name' | 'external_id'>,
): string {
if (agent.external_id === null) {
throw new HTTPException(500, { message: AGENT_EXTERNAL_ID_REQUIRED });
const error = new HTTPException(500, { message: AGENT_EXTERNAL_ID_REQUIRED });
captureCriticalException(error, {
tags: { module: 'truefoundry', operation: 'requireExternalId' },
extra: {
agent_id: agent.id,
tenant_id: agent.tenant_id,
agent_name: agent.name,
},
});
Comment thread
thesujai marked this conversation as resolved.
throw error;
}
return agent.external_id;
}
Expand Down
Loading
Loading