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
18 changes: 9 additions & 9 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,15 @@ Do not add app-specific keys under `user.*`. Put facts about the request in

PostgreSQL queries that are slow, frequent, or failing.

Spans: `db.query`
Spans: Sentry's automatic `pg` and Neon spans (`sentry.op` `db`, origin
`auto.db.postgres` or `auto.db.neon`). Node Postgres also adds `pg-pool.connect`
and `pg.connect` spans.

Attributes: `db.system.name`, `db.namespace`, `db.operation.name`,
`db.collection.name`, `db.query.summary`, `db.query.text`,
`db.response.status_code`, `error.type`, `server.address`, `server.port`,
`app.db.driver`
Attributes: `db.system.name`, `db.namespace`, `db.user`, `db.query.summary`,
`db.query.text`, `server.address`, `server.port`, `error.type`

`db.query.text` keeps parameter placeholders and never includes parameter values.
All inline SQL literals use `?`. SQL comments are removed.
The SDK replaces inline SQL literals with `?` and removes SQL comments.
Group by `db.query.text` to identify one query shape. Group by
`db.query.summary` for a lower-cardinality operation and target view.

Expand All @@ -189,10 +189,10 @@ Events: `webhook.response.unsuccessful`, `webhook.handler.failed`,
`slack.event.persist.failed`, `slack.event.routing.failed`,
`slack.event.enqueue.failed`

Spans: `http.server.request`, `slack.message.ingress`
Spans: Sentry's automatic `http.server` span, `slack.message.ingress`

Attributes: `http.request.method`, `http.response.status_code`, `url.path`,
`app.request.id`
Attributes: `http.request.method`, `http.response.status_code`, `url.path`.
Webhook logs carry `app.request.id`.

Signed Slack message and mention events get a `slack.message.ingress` span
before membership checks. It carries conversation, channel, and message IDs,
Expand Down
3 changes: 0 additions & 3 deletions apps/example/instrument.mjs

This file was deleted.

7 changes: 2 additions & 5 deletions apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@
"type": "module",
"scripts": {
"predev": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior-memory build",
"dev": "cross-env NODE_OPTIONS=--import=./instrument.mjs nitro dev",
"dev": "nitro dev",
"prebuild": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior-memory build",
"build": "junior snapshot create && nitro build",
"postbuild": "node scripts/check-vercel-output.mjs",
"preview": "cross-env NODE_OPTIONS=--import=./instrument.mjs nitro preview",
"preview": "nitro preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opentelemetry/api": "1.9.1",
"@opentelemetry/core": "2.9.0",
"@sentry/junior": "workspace:*",
"@sentry/junior-agent-browser": "workspace:*",
"@sentry/junior-amplitude": "workspace:*",
Expand All @@ -31,7 +29,6 @@
},
"devDependencies": {
"@types/node": "^25.9.1",
"cross-env": "^10.1.0",
"jiti": "^2.7.0",
"nitro": "3.0.260522-beta",
"typescript": "^7.0.2"
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/operate/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ related:

## Key spans

- `http.server.request`
- `http.server`
- `queue.publish`
- `queue.process`
- `chat.turn`
Expand Down
3 changes: 1 addition & 2 deletions packages/junior/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,10 @@
"@logtape/logtape": "^2.1.1",
"@modelcontextprotocol/sdk": "1.32.0",
"@neondatabase/serverless": "^1.1.0",
"@opentelemetry/api": "catalog:",
"@opentelemetry/core": "catalog:",
"@sentry/junior-plugin-api": "workspace:*",
"@sentry/nitro": "catalog:",
"@sentry/node": "catalog:",
"@sentry/server-utils": "catalog:",
"@sinclair/typebox": "^0.34.49",
"@slack/types": "^2.21.1",
"@slack/web-api": "^7.16.0",
Expand Down
1 change: 0 additions & 1 deletion packages/junior/src/chat/guardian/reviewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,6 @@ export function createGuardianActionReviewer(options: {
system: GUARDIAN_ACTION_POLICY,
prompt: guardianPrompt(proposal),
maxTokens: GUARDIAN_REVIEW_MAX_TOKENS,
recordTelemetryPayloads: false,
temperature: 0,
promptName: "junior.guardian_action_review",
signal,
Expand Down
50 changes: 20 additions & 30 deletions packages/junior/src/chat/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1372,17 +1372,10 @@ export function getLogContextAttributes(): LogAttributes {
/** Return inherited log context filtered to attributes valid for the span operation. */
export function getLogContextAttributesForSpan(op: string): LogAttributes {
const attributes = { ...getLogContextAttributes() };
if (op === "gen_ai.invoke_agent" || op === "gen_ai.execute_tool") {
delete attributes["gen_ai.request.model"];
return attributes;
}
if (op === "gen_ai.chat" || op === "gen_ai.embeddings") {
delete attributes["gen_ai.request.model"];
if (op !== "gen_ai.invoke_agent" && op !== "gen_ai.execute_tool") {
delete attributes["gen_ai.agent.name"];
delete attributes["gen_ai.request.model"];
return attributes;
}
delete attributes["gen_ai.agent.name"];
delete attributes["gen_ai.request.model"];
return attributes;
}

Expand Down Expand Up @@ -1524,11 +1517,7 @@ function setAttributesOnSpan(
span: Sentry.Span,
attributes: Record<string, unknown>,
): void {
for (const [key, value] of Object.entries(
normalizeSpanAttributes(attributes),
)) {
span.setAttribute(key, value);
}
span.setAttributes(normalizeSpanAttributes(attributes));
}

/**
Expand Down Expand Up @@ -1653,36 +1642,37 @@ export async function withSpan<T>(
callback: (setSpanAttributes: SetSpanAttributes) => Promise<T>,
attributes: Record<string, unknown> = {},
): Promise<T> {
const normalizedAttributes = normalizeSpanAttributes(attributes);

return withLogContext(context, () => {
// Child spans inherit the active log context so nested GenAI spans keep
// conversation/session correlation even when callers pass only delta
// context such as modelId or tool metadata.
const inheritedAttributes = getLogContextAttributesForSpan(op);
return Sentry.startSpan(
return withLogContext(context, () =>
Sentry.startSpan(
{
name,
op,
attributes: {
...inheritedAttributes,
...normalizedAttributes,
// Child spans inherit the active log context so nested GenAI spans
// keep conversation correlation when callers pass only delta context.
...getLogContextAttributesForSpan(op),
...normalizeSpanAttributes(attributes),
},
},
async (span: Sentry.Span) => {
async (span) => {
try {
return await callback((attributes) =>
setAttributesOnSpan(span, attributes),
);
} catch (error) {
setAttributesOnSpan(span, {
"error.type": error instanceof Error ? error.name : typeof error,
});
// Sentry sets the error status but not `error.type`. Keep a more
// specific type that the callback already set.
if (spanToJSON(span).attributes?.["error.type"] === undefined) {
span.setAttribute(
"error.type",
error instanceof Error ? error.name : typeof error,
);
}
throw error;
}
},
);
});
),
);
}

/** Return Sentry-standard trace propagation headers for the active span. */
Expand Down
14 changes: 0 additions & 14 deletions packages/junior/src/chat/pi/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,11 +487,6 @@ export async function completeObject<TSchema extends ZodTypeAny>(params: {
thinkingLevel?: ThinkingLevel;
temperature?: number;
maxTokens?: number;
/**
* `false` retains telemetry spans and metrics while omitting model inputs and
* outputs. Omission preserves the existing payload-recording behavior.
*/
recordTelemetryPayloads?: boolean;
signal?: AbortSignal;
metadata?: Record<string, unknown>;
promptName?: string;
Expand Down Expand Up @@ -520,15 +515,6 @@ export async function completeObject<TSchema extends ZodTypeAny>(params: {
...(params.maxTokens !== undefined
? { maxOutputTokens: params.maxTokens }
: undefined),
...(params.recordTelemetryPayloads === false
? {
experimental_telemetry: {
isEnabled: true,
recordInputs: false,
recordOutputs: false,
},
}
: undefined),
...(params.signal !== undefined
? { abortSignal: params.signal }
: undefined),
Expand Down
3 changes: 1 addition & 2 deletions packages/junior/src/chat/sandbox/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { setSpanAttributes, setSpanStatus } from "@/chat/logging";
import { setSpanAttributes } from "@/chat/logging";
import { extractHttpErrorDetails } from "@/chat/sandbox/http-error-details";
import { getWorkspaceSnapshotNotReadyError } from "@/chat/sandbox/snapshot/not-ready-error";

Expand Down Expand Up @@ -137,7 +137,6 @@ export function throwSandboxOperationError(
: undefined),
"app.sandbox.success": false,
});
setSpanStatus("error");
throw new Error(
details.summary
? `${action} failed (${details.summary})`
Expand Down
59 changes: 25 additions & 34 deletions packages/junior/src/chat/sandbox/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,40 +372,31 @@ export function createSandbox(options: SandboxOptions): SandboxAccess {
"process.executable.name": "bash",
},
async () => {
try {
const response = await executeBash({
command,
cwd,
...(env ? { env } : undefined),
...(timeoutMs ? { timeoutMs } : undefined),
...(context.signal ? { signal: context.signal } : undefined),
});
setSpanAttributes({
"process.exit.code": response.exitCode,
"app.sandbox.stdout_bytes": Buffer.byteLength(
response.stdout ?? "",
"utf8",
),
"app.sandbox.stderr_bytes": Buffer.byteLength(
response.stderr ?? "",
"utf8",
),
...(response.aborted
? { "error.type": "outcome_unknown" }
: response.exitCode !== 0
? { "error.type": "nonzero_exit" }
: {}),
});
setSpanStatus(response.exitCode === 0 ? "ok" : "error");
return response;
} catch (error) {
setSpanAttributes({
"error.type":
error instanceof Error ? error.name : "sandbox_execute_error",
});
setSpanStatus("error");
throw error;
}
const response = await executeBash({
command,
cwd,
...(env ? { env } : undefined),
...(timeoutMs ? { timeoutMs } : undefined),
...(context.signal ? { signal: context.signal } : undefined),
});
setSpanAttributes({
"process.exit.code": response.exitCode,
"app.sandbox.stdout_bytes": Buffer.byteLength(
response.stdout ?? "",
"utf8",
),
"app.sandbox.stderr_bytes": Buffer.byteLength(
response.stderr ?? "",
"utf8",
),
...(response.aborted
? { "error.type": "outcome_unknown" }
: response.exitCode !== 0
? { "error.type": "nonzero_exit" }
: {}),
});
setSpanStatus(response.exitCode === 0 ? "ok" : "error");
return response;
},
);
// Always consume egress auth/permission signals regardless of exit code.
Expand Down
60 changes: 39 additions & 21 deletions packages/junior/src/chat/sentry-payload-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ type RequestContainer = {
type SentryErrorEvent = Parameters<
NonNullable<Sentry.NodeOptions["beforeSend"]>
>[0];
type SentryTransactionEvent = Parameters<
NonNullable<Sentry.NodeOptions["beforeSendTransaction"]>
type SentrySpan = Parameters<
NonNullable<Sentry.NodeOptions["beforeSendSpan"]>
>[0];
type SentrySpan = Parameters<Parameters<typeof Sentry.withStreamedSpan>[0]>[0];
type SentryLog = Parameters<
NonNullable<Sentry.NodeOptions["beforeSendLog"]>
>[0];
Expand All @@ -36,9 +35,12 @@ const PAYLOAD_ATTRIBUTE_KEYS = new Set([
const URL_ATTRIBUTE_KEYS = new Set([
"http.target",
"http.url",
// Fetch and HTTP breadcrumbs keep the request URL under `url`.
"url",
"url.full",
"url.path",
]);
const SLACK_FILE_HOST = "files.slack.com";
const OAUTH_CALLBACK_PATH = "/api/oauth/callback/";
const LOCAL_CREDENTIAL_PATH = "/api/internal/local-oauth-credentials";
const SECRET_HEADER_NAMES = new Set([
Expand All @@ -59,15 +61,43 @@ function isSecretHeaderAttribute(key: string): boolean {
);
}

function isSlackFileRequest(attributes: AttributeMap): boolean {
return (
attributes["server.address"] === SLACK_FILE_HOST ||
[...URL_ATTRIBUTE_KEYS].some((key) => {
const value = attributes[key];
return (
typeof value === "string" && value.includes(`//${SLACK_FILE_HOST}/`)
);
})
);
}

/** Keep only the host of private Slack file URLs, which name the workspace and file. */
function scrubSlackFileUrls(attributes: AttributeMap): void {
for (const key of URL_ATTRIBUTE_KEYS) {
const value = attributes[key];
if (typeof value !== "string") {
continue;
}
if (value.includes("://")) {
attributes[key] = `https://${SLACK_FILE_HOST}`;
} else {
delete attributes[key];
}
}
}

function scrubSensitiveUrlAttributes(
attributes: AttributeMap | undefined,
): void {
if (!attributes) {
return;
}
// Query strings are not useful enough in telemetry to justify retaining
// OAuth codes, signed state, or other caller-supplied secrets.
// Query strings and fragments are not useful enough in telemetry to justify
// retaining OAuth codes, signed state, or other caller-supplied secrets.
delete attributes["url.query"];
delete attributes["url.fragment"];

const hasLocalCredentialUrl = [...URL_ATTRIBUTE_KEYS].some((key) => {
const value = attributes[key];
Expand All @@ -81,6 +111,10 @@ function scrubSensitiveUrlAttributes(
delete attributes[key];
}
}
if (isSlackFileRequest(attributes)) {
scrubSlackFileUrls(attributes);
return;
}
for (const key of URL_ATTRIBUTE_KEYS) {
const value = attributes[key];
if (typeof value !== "string" || !value.includes(OAUTH_CALLBACK_PATH)) {
Expand Down Expand Up @@ -175,22 +209,6 @@ export function scrubPrivateSentryEvent(
return event;
}

/** Remove private conversation payloads and boundary secrets from transactions. */
export function scrubPrivateSentryTransaction(
event: SentryTransactionEvent,
): SentryTransactionEvent | null {
const eventRecord = event as AttributeContainer;
scrubContainer(eventRecord);
scrubSensitiveRequest(event.request as RequestContainer | undefined);
scrubContainer(event.contexts?.trace as AttributeContainer);

for (const span of event.spans ?? []) {
scrubContainer(span);
}

return event;
}

/** Remove private conversation payloads and boundary secrets from streamed spans. */
export function scrubPrivateSentrySpan(span: SentrySpan): SentrySpan {
scrubContainer(span);
Expand Down
Loading
Loading