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: 13 additions & 5 deletions packages/junior/src/chat/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
import { toOptionalNumber, toOptionalString } from "@/chat/coerce";
import * as Sentry from "@/chat/sentry";
import type { AgentTurnUsage } from "@/chat/usage";
import { getDeploymentTelemetryAttributes } from "@/deployment";

type Primitive = string | number | boolean;
type AttributeValue = Primitive | string[];
Expand Down Expand Up @@ -125,6 +126,7 @@ function normalizeGenAiFinishReasons(value: unknown): unknown {

const contextStorage = new AsyncLocalStorage<LogAttributes>();
const logRecordSinks = new Set<(record: EmittedLogRecord) => void>();
const deploymentLogAttributes = getDeploymentTelemetryAttributes();
type ConsoleTextStyle = Parameters<typeof styleText>[0];
const LOGTAPE_BODY_KEY = "__logtape_body";
const ROOT_LOGGER_CATEGORY = ["junior"] as const;
Expand Down Expand Up @@ -1120,11 +1122,17 @@ function emitRecord(
const contextAttributes = ownsLogTapeBackend
? undefined
: contextStorage.getStore();
const attributes = mergeAttributes(contextAttributes, traceAttributes, {
"event.name": normalizedEventName,
...(source ? { "app.log.source": source } : {}),
...attrs,
});
const attributes = mergeAttributes(
contextAttributes,
traceAttributes,
{
"event.name": normalizedEventName,
...(source ? { "app.log.source": source } : {}),
...attrs,
},
// Deployment identity is process-owned and must win over event-local attrs.
deploymentLogAttributes,
);

if (usesDirectEmissionFallback) {
emitDirect(level, normalizedEventName, message, attributes);
Expand Down
27 changes: 27 additions & 0 deletions packages/junior/src/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,30 @@ export const JUNIOR_CONVERSATION_WORK_CALLBACK_ROUTE =
"/api/internal/agent/continue";
export const LEGACY_JUNIOR_CONVERSATION_WORK_FUNCTION =
"api/internal/agent/continue.ts";

function toOptionalTrimmed(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

/** Resolve the deployment version used for release and telemetry correlation. */
export function getDeploymentServiceVersion(): string | undefined {
return (
toOptionalTrimmed(process.env.SENTRY_RELEASE) ??
toOptionalTrimmed(process.env.VERCEL_GIT_COMMIT_SHA)
);
}

/** Resolve deployment-scoped telemetry attributes from host environment. */
export function getDeploymentTelemetryAttributes(): Record<string, string> {
const attributes: Record<string, string> = {};
const serviceVersion = getDeploymentServiceVersion();
const deploymentId = toOptionalTrimmed(process.env.VERCEL_DEPLOYMENT_ID);
if (serviceVersion) {
attributes["service.version"] = serviceVersion;
}
if (deploymentId) {
attributes["deployment.id"] = deploymentId;
}
return attributes;
}
21 changes: 20 additions & 1 deletion packages/junior/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import * as Sentry from "@/chat/sentry";
import {
getDeploymentServiceVersion,
getDeploymentTelemetryAttributes,
} from "@/deployment";

function getSampleRate(value: string | undefined, fallback: number): number {
if (!value) return fallback;
Expand All @@ -22,20 +26,35 @@ export function initSentry(): void {

const dsn = process.env.SENTRY_DSN;
const enableLogs = getBoolean(process.env.SENTRY_ENABLE_LOGS, Boolean(dsn));
const serviceVersion = getDeploymentServiceVersion();
const deploymentSpanAttributes = getDeploymentTelemetryAttributes();
Comment thread
cursor[bot] marked this conversation as resolved.

Sentry.init({
dsn,
environment:
process.env.SENTRY_ENVIRONMENT ??
process.env.VERCEL_ENV ??
process.env.NODE_ENV,
release: process.env.SENTRY_RELEASE ?? process.env.VERCEL_GIT_COMMIT_SHA,
release: serviceVersion,
tracesSampleRate: getSampleRate(process.env.SENTRY_TRACES_SAMPLE_RATE, 1),
sendDefaultPii: true,
enabled: Boolean(dsn),
enableLogs,
registerEsmLoaderHooks: false,
streamGenAiSpans: true,
// Keep deployment identity centralized so every emitted Sentry span carries it.
beforeSendSpan(span) {
if (Object.keys(deploymentSpanAttributes).length === 0) {
return span;
}

span.data = {
...span.data,
...deploymentSpanAttributes,
};

return span;
},
integrations: [
Sentry.vercelAIIntegration({
recordInputs: true,
Expand Down
78 changes: 78 additions & 0 deletions packages/junior/tests/unit/instrumentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from "vitest";

const ORIGINAL_SENTRY_DSN = process.env.SENTRY_DSN;
const ORIGINAL_SENTRY_RELEASE = process.env.SENTRY_RELEASE;
const ORIGINAL_VERCEL_DEPLOYMENT_ID = process.env.VERCEL_DEPLOYMENT_ID;
const ORIGINAL_VERCEL_GIT_COMMIT_SHA = process.env.VERCEL_GIT_COMMIT_SHA;

function resetEnv(): void {
if (ORIGINAL_SENTRY_DSN === undefined) {
delete process.env.SENTRY_DSN;
} else {
process.env.SENTRY_DSN = ORIGINAL_SENTRY_DSN;
}
if (ORIGINAL_SENTRY_RELEASE === undefined) {
delete process.env.SENTRY_RELEASE;
} else {
process.env.SENTRY_RELEASE = ORIGINAL_SENTRY_RELEASE;
}
if (ORIGINAL_VERCEL_DEPLOYMENT_ID === undefined) {
delete process.env.VERCEL_DEPLOYMENT_ID;
} else {
process.env.VERCEL_DEPLOYMENT_ID = ORIGINAL_VERCEL_DEPLOYMENT_ID;
}
if (ORIGINAL_VERCEL_GIT_COMMIT_SHA === undefined) {
delete process.env.VERCEL_GIT_COMMIT_SHA;
} else {
process.env.VERCEL_GIT_COMMIT_SHA = ORIGINAL_VERCEL_GIT_COMMIT_SHA;
}
}

async function loadInstrumentationModule() {
vi.resetModules();
const init = vi.fn();
vi.doMock("@/chat/sentry", () => ({
getClient: () => undefined,
init,
vercelAIIntegration: () => ({ name: "vercel-ai" }),
}));
const instrumentation = await import("@/instrumentation");
return { init, instrumentation };
}

afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock("@/chat/sentry");
resetEnv();
});

describe("initSentry", () => {
it("adds deployment metadata to outgoing spans", async () => {
process.env.SENTRY_DSN = "https://public@example.com/1";
process.env.SENTRY_RELEASE = " ";
process.env.VERCEL_DEPLOYMENT_ID = "dpl_123";
process.env.VERCEL_GIT_COMMIT_SHA = "git-sha";

const { init, instrumentation } = await loadInstrumentationModule();
instrumentation.initSentry();

expect(init).toHaveBeenCalledTimes(1);
const options = init.mock.calls[0]?.[0];
expect(options?.release).toBe("git-sha");
expect(options?.beforeSendSpan).toBeTypeOf("function");

const span = {
data: {
"deployment.id": "span-deployment",
"service.version": "span-version",
},
};

expect(options.beforeSendSpan(span)).toBe(span);
expect(span.data).toMatchObject({
"deployment.id": "dpl_123",
"service.version": "git-sha",
});
});
});
79 changes: 79 additions & 0 deletions packages/junior/tests/unit/logging/deployment-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { EmittedLogRecord } from "@/chat/logging";

const ORIGINAL_SENTRY_RELEASE = process.env.SENTRY_RELEASE;
const ORIGINAL_VERCEL_DEPLOYMENT_ID = process.env.VERCEL_DEPLOYMENT_ID;
const ORIGINAL_VERCEL_GIT_COMMIT_SHA = process.env.VERCEL_GIT_COMMIT_SHA;

async function loadLoggingModule() {
vi.resetModules();
vi.doMock("@/chat/sentry", () => ({
captureException: undefined,
captureMessage: undefined,
getActiveSpan: () => undefined,
logger: {},
setTag: undefined,
setUser: undefined,
spanToJSON: () => ({}),
withScope: undefined,
}));
return await import("@/chat/logging");
}

function resetEnv(): void {
if (ORIGINAL_SENTRY_RELEASE === undefined) {
delete process.env.SENTRY_RELEASE;
} else {
process.env.SENTRY_RELEASE = ORIGINAL_SENTRY_RELEASE;
}
if (ORIGINAL_VERCEL_DEPLOYMENT_ID === undefined) {
delete process.env.VERCEL_DEPLOYMENT_ID;
} else {
process.env.VERCEL_DEPLOYMENT_ID = ORIGINAL_VERCEL_DEPLOYMENT_ID;
}
if (ORIGINAL_VERCEL_GIT_COMMIT_SHA === undefined) {
delete process.env.VERCEL_GIT_COMMIT_SHA;
} else {
process.env.VERCEL_GIT_COMMIT_SHA = ORIGINAL_VERCEL_GIT_COMMIT_SHA;
}
}

afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock("@/chat/sentry");
resetEnv();
});

describe("deployment log attributes", () => {
it("adds deployment metadata to emitted log records", async () => {
process.env.SENTRY_RELEASE = " ";
process.env.VERCEL_DEPLOYMENT_ID = "dpl_123";
process.env.VERCEL_GIT_COMMIT_SHA = "git-sha";

const { log, registerLogRecordSink } = await loadLoggingModule();
const records: EmittedLogRecord[] = [];
const unregister = registerLogRecordSink((record) => {
records.push(record);
});

try {
log.warn(
"deployment_context_test",
{
"deployment.id": "caller-deployment",
"service.version": "caller-version",
},
"Deployment context test",
);
} finally {
unregister();
}

expect(records).toHaveLength(1);
expect(records[0]?.attributes).toMatchObject({
"deployment.id": "dpl_123",
"service.version": "git-sha",
});
});
});
15 changes: 14 additions & 1 deletion specs/instrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Metadata

- Created: 2026-02-25
- Last Edited: 2026-03-06
- Last Edited: 2026-06-09

## Purpose

Expand All @@ -23,6 +23,19 @@ Define the canonical logging/tracing instrumentation contracts and shared policy
- required aggregation cannot be recovered from existing span/log attributes, or
- a critical SLO/SLA alert needs a dedicated low-latency metric path.

## Attribute Scope Policy

Telemetry attributes that describe the emitting process or deployment may be
attached to every span and log record so each record is independently
queryable. Examples include `service.version`, `deployment.id`, and
`deployment.environment.name`.

Operation-local attributes must stay on the span or log record that directly
observes them. For example, `http.response.status_code` belongs on the
corresponding `http.server`/`http.client` span and must not be copied to sibling
spans only to make cross-span queries easier. Treat spans like logs/events for
attribute scope: global context can be repeated, but local facts stay local.

## Specs

- [Structured Logging Spec](./logging.md)
Expand Down
3 changes: 2 additions & 1 deletion specs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Metadata

- Created: 2026-02-24
- Last Edited: 2026-05-28
- Last Edited: 2026-06-09

## Purpose

Expand Down Expand Up @@ -151,6 +151,7 @@ Rules:

- `service.name`
- `service.version`
- `deployment.id`
- `deployment.environment.name`
- `event.name`

Expand Down
3 changes: 2 additions & 1 deletion specs/otel-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Metadata

- Created: 2026-02-25
- Last Edited: 2026-05-30
- Last Edited: 2026-06-09

## Purpose

Expand Down Expand Up @@ -33,6 +33,7 @@ This file is the canonical attribute and naming map for instrumentation in this

- `service.name`
- `service.version`
- `deployment.id`
- `deployment.environment.name`
- `trace_id`
- `span_id`
Expand Down
3 changes: 2 additions & 1 deletion specs/tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Metadata

- Created: 2026-02-25
- Last Edited: 2026-05-30
- Last Edited: 2026-06-09

## Purpose

Expand Down Expand Up @@ -49,6 +49,7 @@ Define the canonical tracing contract for span naming, boundaries, attributes, a

- `service.name` (when available)
- `service.version` (when available)
- `deployment.id` (when available)
- `deployment.environment.name` (when available)

### Correlation Context
Expand Down
Loading