From ab03f5d12383ad502124f56142e2b9a1b01256ab Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:06:18 +0000 Subject: [PATCH 1/8] refactor(logging): Decouple async log context Co-Authored-By: David Cramer --- .../junior/src/chat/agent-dispatch/runner.ts | 4 +- packages/junior/src/chat/agent/index.ts | 20 +++---- .../junior/src/chat/ingress/slack-webhook.ts | 14 ++--- .../junior/src/chat/ingress/slash-command.ts | 2 +- packages/junior/src/chat/log-context.ts | 37 ++++++++++++ packages/junior/src/chat/logging.ts | 57 +++++++++---------- packages/junior/src/chat/pi/client.ts | 8 +-- .../junior/src/chat/runtime/reply-executor.ts | 20 +++---- .../junior/src/chat/runtime/slack-resume.ts | 9 +-- .../junior/src/chat/runtime/slack-runtime.ts | 28 ++++----- .../src/chat/services/context-compaction.ts | 6 +- .../src/chat/services/conversation-memory.ts | 6 +- .../chat/services/subscribed-reply-policy.ts | 6 +- .../junior/src/chat/services/turn-router.ts | 6 +- .../src/chat/services/turn-session-record.ts | 6 +- .../src/chat/slack/assistant-thread/title.ts | 18 +++--- .../junior/src/chat/slack/vision-context.ts | 54 +++++++++--------- .../tests/unit/logging/log-context.test.ts | 40 +++++++++++++ .../tests/unit/logging/sentry-context.test.ts | 22 +++---- 19 files changed, 219 insertions(+), 144 deletions(-) create mode 100644 packages/junior/src/chat/log-context.ts create mode 100644 packages/junior/tests/unit/logging/log-context.test.ts diff --git a/packages/junior/src/chat/agent-dispatch/runner.ts b/packages/junior/src/chat/agent-dispatch/runner.ts index 48e7d17a1..09fe77c2f 100644 --- a/packages/junior/src/chat/agent-dispatch/runner.ts +++ b/packages/junior/src/chat/agent-dispatch/runner.ts @@ -206,8 +206,8 @@ export async function runAgentDispatchSlice( const turnId = getDispatchTurnId(dispatch.id); const logContext = { conversationId, - slackThreadId: conversationId, - slackChannelId: dispatch.destination.channelId, + messageConversationId: conversationId, + destinationName: dispatch.destination.channelId, runId: dispatch.id, actorType: dispatch.actor.platform, actorId: dispatch.actor.name, diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 3312f9b18..2015c46c8 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -294,9 +294,9 @@ async function executeAgentRunInPrivacyContext( const shouldTrace = shouldEmitDevAgentTrace(); const spanContext: LogContext = { conversationId, - slackThreadId: slackSource ? conversationId : undefined, - slackUserId: slackActor?.userId, - slackChannelId: slackDestination?.channelId, + messageConversationId: slackSource ? conversationId : undefined, + userId: slackActor?.userId, + destinationName: slackDestination?.channelId, runId, ...credentialActorLogContext, assistantUserName: botConfig.userName, @@ -593,10 +593,10 @@ async function executeAgentRunInPrivacyContext( setTags({ conversationId: spanContext.conversationId, - slackThreadId: slackSource ? conversationId : undefined, - slackUserId: slackActor?.userId, - slackChannelId: slackDestination?.channelId, - runId, + messageConversationId: spanContext.messageConversationId, + userId: spanContext.userId, + destinationName: spanContext.destinationName, + runId: spanContext.runId, ...credentialActorLogContext, assistantUserName: botConfig.userName, modelId: activeModelId, @@ -1197,9 +1197,9 @@ async function executeAgentRunInPrivacyContext( "assistant_reply_generation_failed", { conversationId, - slackThreadId: slackSource ? conversationId : undefined, - slackUserId: slackActor?.userId, - slackChannelId: slackDestination?.channelId, + messageConversationId: slackSource ? conversationId : undefined, + userId: slackActor?.userId, + destinationName: slackDestination?.channelId, runId, ...credentialActorLogContext, assistantUserName: botConfig.userName, diff --git a/packages/junior/src/chat/ingress/slack-webhook.ts b/packages/junior/src/chat/ingress/slack-webhook.ts index 4b560db69..80b0f92ab 100644 --- a/packages/junior/src/chat/ingress/slack-webhook.ts +++ b/packages/junior/src/chat/ingress/slack-webhook.ts @@ -338,7 +338,7 @@ async function handleSlackEvent(args: { ); } catch (error) { logException(error, "slack_app_home_publish_failed", { - slackUserId: userId, + userId, }); } } @@ -485,7 +485,7 @@ async function handleSlashCommandForm(args: { await withSpan( "chat.slash_command", "chat.slash_command", - { slackUserId: userId }, + { userId: userId }, async () => { await handleSlashCommand({ adapter: args.adapter, @@ -529,7 +529,7 @@ async function handleInteractivePayload(args: { await withSpan( "chat.app_home_disconnect", "chat.app_home_disconnect", - { slackUserId: userId }, + { userId: userId }, async () => { try { await unlinkProvider(userId, provider, args.userTokenStore); @@ -537,7 +537,7 @@ async function handleInteractivePayload(args: { logException( error, "app_home_disconnect_unlink_failed", - { slackUserId: userId }, + { userId: userId }, { "app.credential.provider": provider }, ); } @@ -548,7 +548,7 @@ async function handleInteractivePayload(args: { logException( error, "app_home_disconnect_publish_failed", - { slackUserId: userId }, + { userId: userId }, { "app.credential.provider": provider }, ); } @@ -603,7 +603,7 @@ async function handleSlackForm(args: { }), ).catch((error) => { logException(error, "slash_command_failed", { - slackUserId: params.get("user_id") ?? undefined, + userId: params.get("user_id") ?? undefined, }); }), ); @@ -635,7 +635,7 @@ async function handleSlackForm(args: { }), ).catch((error) => { logException(error, "slack_interactive_payload_failed", { - slackUserId: payload.user?.id?.trim() || undefined, + userId: payload.user?.id?.trim() || undefined, }); }), ); diff --git a/packages/junior/src/chat/ingress/slash-command.ts b/packages/junior/src/chat/ingress/slash-command.ts index 16dfc0b83..961b2a34c 100644 --- a/packages/junior/src/chat/ingress/slash-command.ts +++ b/packages/junior/src/chat/ingress/slash-command.ts @@ -90,7 +90,7 @@ async function handleUnlink( logInfo( "slash_command_unlink", - { slackUserId: actorId }, + { userId: actorId }, { "app.credential.provider": provider }, `Unlinked ${formatProviderLabel(provider)} account via ${getCommandName()} slash command`, ); diff --git a/packages/junior/src/chat/log-context.ts b/packages/junior/src/chat/log-context.ts new file mode 100644 index 000000000..76f073ded --- /dev/null +++ b/packages/junior/src/chat/log-context.ts @@ -0,0 +1,37 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export type LogAttributeValue = string | number | boolean | string[]; +export type LogAttributes = Record; + +/** Async context domain inherited by logs and spans in the current operation. */ +export const logContextStorage = new AsyncLocalStorage(); + +function mergeLogAttributes( + current: LogAttributes | undefined, + next: LogAttributes, +): LogAttributes { + return { ...current, ...next }; +} + +/** Run an operation with additional log attributes without changing its callers. */ +export function bindLogAttributes( + attributes: LogAttributes, + callback: () => T, +): T { + return logContextStorage.run( + mergeLogAttributes(logContextStorage.getStore(), attributes), + callback, + ); +} + +/** Add log attributes to the current async operation. */ +export function extendLogAttributes(attributes: LogAttributes): void { + logContextStorage.enterWith( + mergeLogAttributes(logContextStorage.getStore(), attributes), + ); +} + +/** Read the attributes bound to the current async operation. */ +export function getBoundLogAttributes(): LogAttributes { + return logContextStorage.getStore() ?? {}; +} diff --git a/packages/junior/src/chat/logging.ts b/packages/junior/src/chat/logging.ts index af595830e..cfcd3b258 100644 --- a/packages/junior/src/chat/logging.ts +++ b/packages/junior/src/chat/logging.ts @@ -1,4 +1,3 @@ -import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; import { styleText } from "node:util"; import { @@ -15,6 +14,13 @@ import type { LogLevel as ChatSdkLogLevel, } from "chat"; import { toOptionalNumber, toOptionalString } from "@/chat/coerce"; +import { + bindLogAttributes, + extendLogAttributes, + getBoundLogAttributes, + logContextStorage, + type LogAttributes, +} from "@/chat/log-context"; import { normalizeIdentityEmail } from "@/chat/identities/identity"; import { getActiveSpan } from "@/chat/sentry"; import * as Sentry from "@/chat/sentry"; @@ -23,7 +29,7 @@ import { getDeploymentTelemetryAttributes } from "@/deployment"; type Primitive = string | number | boolean; type AttributeValue = Primitive | string[]; -export type LogAttributes = Record; +export type { LogAttributes } from "@/chat/log-context"; export type LogLevel = "debug" | "info" | "warn" | "error"; export interface EmittedLogRecord { attributes: LogAttributes; @@ -36,11 +42,11 @@ export interface LogContext { conversationId?: string; platform?: string; requestId?: string; - slackThreadId?: string; - slackUserId?: string; - slackUserName?: string; - slackUserEmail?: string; - slackChannelId?: string; + messageConversationId?: string; + destinationName?: string; + userId?: string; + userName?: string; + userEmail?: string; runId?: string; actorType?: string; actorId?: string; @@ -141,7 +147,6 @@ function normalizeGenAiFinishReasons(value: unknown): unknown { ); } -const contextStorage = new AsyncLocalStorage(); const logRecordSinks = new Set<(record: EmittedLogRecord) => void>(); const deploymentLogAttributes = getDeploymentTelemetryAttributes(); type ConsoleTextStyle = Parameters[0]; @@ -432,10 +437,10 @@ function contextToAttributes(context: LogContext): LogAttributes { "app.request.id": context.requestId, "messaging.system": context.platform === "slack" ? "slack" : context.platform, - "messaging.message.conversation_id": context.slackThreadId, - "messaging.destination.name": context.slackChannelId, - "enduser.id": context.slackUserId, - "enduser.pseudo.id": context.slackUserName, + "messaging.message.conversation_id": context.messageConversationId, + "messaging.destination.name": context.destinationName, + "enduser.id": context.userId, + "enduser.pseudo.id": context.userName, "app.run.id": context.runId, "app.actor.type": context.actorType, "app.actor.id": context.actorId, @@ -615,7 +620,7 @@ function ensureLoggerBackend(): void { lowestLevel: "error", }, ], - contextLocalStorage: contextStorage, + contextLocalStorage: logContextStorage, }); ownsLogTapeBackend = true; rootLogger = getLogger([...ROOT_LOGGER_CATEGORY]); @@ -1138,7 +1143,7 @@ function emitRecord( const source = getLogSource([...ROOT_LOGGER_CATEGORY, ...category]); const contextAttributes = ownsLogTapeBackend ? undefined - : contextStorage.getStore(); + : getBoundLogAttributes(); const attributes = mergeAttributes( contextAttributes, traceAttributes, @@ -1258,7 +1263,7 @@ export const log = { setSentryScopeContext(scope, context); } for (const [key, value] of Object.entries( - mergeAttributes(contextStorage.getStore(), attrs), + mergeAttributes(getBoundLogAttributes(), attrs), )) { scope.setExtra(key, value); } @@ -1393,23 +1398,15 @@ export function withLogContext( context: LogContext, callback: () => Promise, ): Promise { - const next = mergeAttributes( - contextStorage.getStore(), - contextToAttributes(context), - ); - return contextStorage.run(next, callback); + return bindLogAttributes(contextToAttributes(context), callback); } export function setLogContext(context: LogContext): void { - const merged = mergeAttributes( - contextStorage.getStore(), - contextToAttributes(context), - ); - contextStorage.enterWith(merged); + extendLogAttributes(contextToAttributes(context)); } export function getLogContextAttributes(): LogAttributes { - return contextStorage.getStore() ?? {}; + return getBoundLogAttributes(); } /** Return inherited log context filtered to attributes valid for the span operation. */ @@ -1479,11 +1476,11 @@ export function setSentryTagsFromContext(context: LogContext): void { function sentryUserIdentityFromContext( context: LogContext, ): SentryUserIdentity | undefined { - if (context.slackUserId) { - const email = normalizeIdentityEmail(context.slackUserEmail); + if (context.userId) { + const email = normalizeIdentityEmail(context.userEmail); return { - id: context.slackUserId, - ...(context.slackUserName ? { username: context.slackUserName } : {}), + id: context.userId, + ...(context.userName ? { username: context.userName } : {}), ...(email ? { email } : {}), }; } diff --git a/packages/junior/src/chat/pi/client.ts b/packages/junior/src/chat/pi/client.ts index 3ee714f00..21f825aaf 100644 --- a/packages/junior/src/chat/pi/client.ts +++ b/packages/junior/src/chat/pi/client.ts @@ -266,9 +266,9 @@ function logContextFromMetadata( : typeof metadata?.threadId === "string" ? metadata.threadId : undefined; - const slackThreadId = + const messageConversationId = typeof metadata?.threadId === "string" ? metadata.threadId : undefined; - const slackChannelId = + const destinationName = typeof metadata?.channelId === "string" ? metadata.channelId : undefined; const runId = typeof metadata?.runId === "string" ? metadata.runId : undefined; @@ -276,8 +276,8 @@ function logContextFromMetadata( return { modelId, ...(conversationId ? { conversationId } : {}), - ...(slackThreadId ? { slackThreadId } : {}), - ...(slackChannelId ? { slackChannelId } : {}), + ...(messageConversationId ? { messageConversationId } : {}), + ...(destinationName ? { destinationName } : {}), ...(runId ? { runId } : {}), }; } diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index f257282b0..954835123 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -491,9 +491,9 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { "chat.reply", { conversationId, - slackThreadId: threadId, - slackUserId: message.author.userId, - slackChannelId: channelId, + messageConversationId: threadId, + userId: message.author.userId, + destinationName: channelId, runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), @@ -562,9 +562,9 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const turnId = buildDeterministicTurnId(message.id); const turnTraceContext = { conversationId, - slackThreadId: threadId, - slackUserId: message.author.userId, - slackChannelId: channelId, + messageConversationId: threadId, + userId: message.author.userId, + destinationName: channelId, runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), @@ -966,7 +966,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { }); } if (actor?.userName) { - setTags({ slackUserName: actor.userName }); + setTags({ userName: actor.userName }); } const turnAttachments = collectTurnAttachments( message, @@ -1455,9 +1455,9 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { let reply = outcome.result; const diagnosticsContext = { - slackThreadId: threadId, - slackUserId: message.author.userId, - slackChannelId: channelId, + messageConversationId: threadId, + userId: message.author.userId, + destinationName: channelId, runId, assistantUserName: botConfig.userName, modelId: reply.diagnostics.modelId, diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index f4769c76c..e92a1a678 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -220,10 +220,11 @@ function getResumeLogContext( const actor = routing?.actor; return { conversationId: args.conversationId, - slackThreadId: lockKey, - slackUserId: isUserActor(actor) ? actor.userId : undefined, - slackUserName: isUserActor(actor) ? actor.userName : undefined, - slackChannelId: args.channelId, + messageConversationId: lockKey, + userId: isUserActor(actor) ? actor.userId : undefined, + userName: isUserActor(actor) ? actor.userName : undefined, + destinationName: args.channelId, + runId: routing?.correlation?.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), }; diff --git a/packages/junior/src/chat/runtime/slack-runtime.ts b/packages/junior/src/chat/runtime/slack-runtime.ts index ae18ef7a2..91d6dec29 100644 --- a/packages/junior/src/chat/runtime/slack-runtime.ts +++ b/packages/junior/src/chat/runtime/slack-runtime.ts @@ -103,12 +103,12 @@ function shouldRethrowTurnControlError(error: unknown): boolean { type RuntimeLogContext = Record & { assistantUserName: string; conversationId?: string; + destinationName?: string; + messageConversationId?: string; modelId: string; - slackChannelId?: string; - slackThreadId?: string; - slackUserId?: string; - slackUserName?: string; runId?: string; + userId?: string; + userName?: string; }; export interface SlackTurnRuntimeDependencies { @@ -350,10 +350,10 @@ function buildLogContext( ): RuntimeLogContext { return { conversationId: args.threadId ?? args.runId, - slackThreadId: args.threadId, - slackUserId: args.actorId, - slackUserName: args.actorUserName, - slackChannelId: args.channelId, + messageConversationId: args.threadId, + userId: args.actorId, + userName: args.actorUserName, + destinationName: args.channelId, runId: args.runId, assistantUserName: deps.assistantUserName, modelId: deps.modelId, @@ -1249,9 +1249,9 @@ export function createSlackTurnRuntime< error, "assistant_thread_started_handler_failed", { - slackThreadId: event.threadId, - slackUserId: event.userId, - slackChannelId: event.channelId, + messageConversationId: event.threadId, + userId: event.userId, + destinationName: event.channelId, assistantUserName: deps.assistantUserName, modelId: deps.modelId, }, @@ -1274,9 +1274,9 @@ export function createSlackTurnRuntime< error, "assistant_context_changed_handler_failed", { - slackThreadId: event.threadId, - slackUserId: event.userId, - slackChannelId: event.channelId, + messageConversationId: event.threadId, + userId: event.userId, + destinationName: event.channelId, assistantUserName: deps.assistantUserName, modelId: deps.modelId, }, diff --git a/packages/junior/src/chat/services/context-compaction.ts b/packages/junior/src/chat/services/context-compaction.ts index 69768723b..189230b2e 100644 --- a/packages/junior/src/chat/services/context-compaction.ts +++ b/packages/junior/src/chat/services/context-compaction.ts @@ -411,9 +411,9 @@ async function maybeCompactWithDeps( logWarn( "context_compaction_summary_failed", { - slackThreadId: args.metadata?.threadId, - slackUserId: args.metadata?.actorId, - slackChannelId: args.metadata?.channelId, + messageConversationId: args.metadata?.threadId, + userId: args.metadata?.actorId, + destinationName: args.metadata?.channelId, runId: args.metadata?.runId, assistantUserName: botConfig.userName, modelId: botConfig.fastModelId, diff --git a/packages/junior/src/chat/services/conversation-memory.ts b/packages/junior/src/chat/services/conversation-memory.ts index a6ace43d6..e514a84c3 100644 --- a/packages/junior/src/chat/services/conversation-memory.ts +++ b/packages/junior/src/chat/services/conversation-memory.ts @@ -354,9 +354,9 @@ async function summarizeConversationChunk( logWarn( "conversation_compaction_summary_failed", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: botConfig.fastModelId, diff --git a/packages/junior/src/chat/services/subscribed-reply-policy.ts b/packages/junior/src/chat/services/subscribed-reply-policy.ts index 879d92e9a..28e426326 100644 --- a/packages/junior/src/chat/services/subscribed-reply-policy.ts +++ b/packages/junior/src/chat/services/subscribed-reply-policy.ts @@ -33,9 +33,9 @@ export function createSubscribedReplyPolicy( logWarn( "subscribed_message_classifier_failed", { - slackThreadId: input.context.threadId, - slackUserId: input.context.actorId, - slackChannelId: input.context.channelId, + messageConversationId: input.context.threadId, + userId: input.context.actorId, + destinationName: input.context.channelId, runId: input.context.runId, assistantUserName: botConfig.userName, modelId: botConfig.fastModelId, diff --git a/packages/junior/src/chat/services/turn-router.ts b/packages/junior/src/chat/services/turn-router.ts index f341ccb84..fbfa2e78c 100644 --- a/packages/junior/src/chat/services/turn-router.ts +++ b/packages/junior/src/chat/services/turn-router.ts @@ -213,9 +213,9 @@ export async function selectTurnRoute(args: { }); const logContext: LogContext = { - slackThreadId: args.context?.threadId, - slackChannelId: args.context?.channelId, - slackUserId: args.context?.actorId, + messageConversationId: args.context?.threadId, + destinationName: args.context?.channelId, + userId: args.context?.actorId, runId: args.context?.runId, modelId: args.fastModelId, }; diff --git a/packages/junior/src/chat/services/turn-session-record.ts b/packages/junior/src/chat/services/turn-session-record.ts index 672a628c1..f1dedec03 100644 --- a/packages/junior/src/chat/services/turn-session-record.ts +++ b/packages/junior/src/chat/services/turn-session-record.ts @@ -54,9 +54,9 @@ function logSessionRecordError( error, eventName, { - slackThreadId: args.logContext.threadId, - slackUserId: args.logContext.actorId, - slackChannelId: args.logContext.channelId, + messageConversationId: args.logContext.threadId, + userId: args.logContext.actorId, + destinationName: args.logContext.channelId, runId: args.logContext.runId, assistantUserName: args.logContext.assistantUserName, modelId: args.modelId, diff --git a/packages/junior/src/chat/slack/assistant-thread/title.ts b/packages/junior/src/chat/slack/assistant-thread/title.ts index facbb4181..82911391b 100644 --- a/packages/junior/src/chat/slack/assistant-thread/title.ts +++ b/packages/junior/src/chat/slack/assistant-thread/title.ts @@ -64,9 +64,9 @@ export function maybeUpdateAssistantTitle(args: { logWarn( "thread_title_generation_failed", { - slackThreadId: args.threadId, - slackUserId: args.actorId, - slackChannelId: args.channelId, + messageConversationId: args.threadId, + userId: args.actorId, + destinationName: args.channelId, runId: args.runId, assistantUserName: args.assistantUserName, modelId: args.modelId, @@ -108,9 +108,9 @@ export function maybeUpdateAssistantTitle(args: { logError( "thread_title_generation_permission_denied", { - slackThreadId: args.threadId, - slackUserId: args.actorId, - slackChannelId: args.channelId, + messageConversationId: args.threadId, + userId: args.actorId, + destinationName: args.channelId, runId: args.runId, assistantUserName: args.assistantUserName, modelId: args.modelId, @@ -122,9 +122,9 @@ export function maybeUpdateAssistantTitle(args: { logWarn( "thread_title_slack_update_failed", { - slackThreadId: args.threadId, - slackUserId: args.actorId, - slackChannelId: args.channelId, + messageConversationId: args.threadId, + userId: args.actorId, + destinationName: args.channelId, runId: args.runId, assistantUserName: args.assistantUserName, modelId: args.modelId, diff --git a/packages/junior/src/chat/slack/vision-context.ts b/packages/junior/src/chat/slack/vision-context.ts index f962cea78..2913c11d2 100644 --- a/packages/junior/src/chat/slack/vision-context.ts +++ b/packages/junior/src/chat/slack/vision-context.ts @@ -314,9 +314,9 @@ async function resolveUserAttachmentsWithDeps( logWarn( "attachment_skipped_size_limit", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), @@ -343,9 +343,9 @@ async function resolveUserAttachmentsWithDeps( logWarn( "image_attachment_processing_failed", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: botConfig.visionModelId ?? standardModelId(botConfig), @@ -364,9 +364,9 @@ async function resolveUserAttachmentsWithDeps( logWarn( "attachment_resolution_failed", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), @@ -431,9 +431,9 @@ async function summarizeConversationImage( logWarn( "conversation_image_vision_failed", { - slackThreadId: args.context.threadId, - slackUserId: args.context.actorId, - slackChannelId: args.context.channelId, + messageConversationId: args.context.threadId, + userId: args.context.actorId, + destinationName: args.context.channelId, runId: args.context.runId, assistantUserName: botConfig.userName, modelId: visionModelId, @@ -505,9 +505,9 @@ async function hydrateConversationVisionContextWithDeps( logWarn( "conversation_image_replies_fetch_failed", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), @@ -589,9 +589,9 @@ async function hydrateConversationVisionContextWithDeps( logWarn( "conversation_image_skipped_size_limit", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), @@ -620,9 +620,9 @@ async function hydrateConversationVisionContextWithDeps( logWarn( "conversation_image_download_failed", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), @@ -642,9 +642,9 @@ async function hydrateConversationVisionContextWithDeps( logWarn( "conversation_image_skipped_size_limit", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), @@ -694,9 +694,9 @@ async function hydrateConversationVisionContextWithDeps( logInfo( "conversation_image_context_hydrated", { - slackThreadId: context.threadId, - slackUserId: context.actorId, - slackChannelId: context.channelId, + messageConversationId: context.threadId, + userId: context.actorId, + destinationName: context.channelId, runId: context.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), diff --git a/packages/junior/tests/unit/logging/log-context.test.ts b/packages/junior/tests/unit/logging/log-context.test.ts new file mode 100644 index 000000000..65a61c415 --- /dev/null +++ b/packages/junior/tests/unit/logging/log-context.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + bindLogAttributes, + extendLogAttributes, + getBoundLogAttributes, +} from "@/chat/log-context"; + +describe("log context", () => { + it("inherits and restores nested async context", async () => { + expect(getBoundLogAttributes()).toEqual({}); + + await bindLogAttributes({ "app.request.id": "outer" }, async () => { + expect(getBoundLogAttributes()).toEqual({ "app.request.id": "outer" }); + + await bindLogAttributes({ "app.run.id": "inner" }, async () => { + await Promise.resolve(); + expect(getBoundLogAttributes()).toEqual({ + "app.request.id": "outer", + "app.run.id": "inner", + }); + }); + + expect(getBoundLogAttributes()).toEqual({ "app.request.id": "outer" }); + }); + + expect(getBoundLogAttributes()).toEqual({}); + }); + + it("extends only the current async context", async () => { + await bindLogAttributes({ "app.request.id": "request" }, async () => { + extendLogAttributes({ "app.run.id": "run" }); + expect(getBoundLogAttributes()).toEqual({ + "app.request.id": "request", + "app.run.id": "run", + }); + }); + + expect(getBoundLogAttributes()).toEqual({}); + }); +}); diff --git a/packages/junior/tests/unit/logging/sentry-context.test.ts b/packages/junior/tests/unit/logging/sentry-context.test.ts index 9d2e0b04a..6d099f48a 100644 --- a/packages/junior/tests/unit/logging/sentry-context.test.ts +++ b/packages/junior/tests/unit/logging/sentry-context.test.ts @@ -37,11 +37,11 @@ describe("Sentry context", () => { setTags({ conversationId: "thread_123", platform: "slack", - slackThreadId: "thread_123", - slackUserId: "U123", - slackUserName: "alice", - slackUserEmail: "Alice@Example.COM", - slackChannelId: "C123", + messageConversationId: "thread_123", + userId: "U123", + userName: "alice", + userEmail: "Alice@Example.COM", + destinationName: "C123", runId: "run_123", assistantUserName: "junior", modelId: "openai/gpt-5.4", @@ -93,9 +93,9 @@ describe("Sentry context", () => { scope as unknown as Parameters[0], { conversationId: "thread_123", - slackUserId: "U123", - slackUserName: "alice", - slackUserEmail: "Alice@Example.COM", + userId: "U123", + userName: "alice", + userEmail: "Alice@Example.COM", modelId: "openai/gpt-5.4", }, ); @@ -133,9 +133,9 @@ describe("Sentry context", () => { new Error("boom"), "turn_failed", { - slackUserId: "U123", - slackUserName: "alice", - slackUserEmail: "Alice@Example.COM", + userId: "U123", + userName: "alice", + userEmail: "Alice@Example.COM", modelId: "openai/gpt-5.4", }, {}, From 8aaceb97a3772f7e0dad1c27786263d4484e7d35 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:11:49 +0000 Subject: [PATCH 2/8] test(logging): Update neutral context assertion --- packages/junior/tests/unit/pi/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/junior/tests/unit/pi/client.test.ts b/packages/junior/tests/unit/pi/client.test.ts index 6de7d73ef..9ebc1eb60 100644 --- a/packages/junior/tests/unit/pi/client.test.ts +++ b/packages/junior/tests/unit/pi/client.test.ts @@ -152,7 +152,7 @@ describe("completeText", () => { >; expect(context).toMatchObject({ conversationId: "slack:D1:123", - slackChannelId: "D1", + destinationName: "D1", modelId: "openai/gpt-4o-mini", }); expect(attributes["app.conversation.privacy"]).toBe("private"); From 3ce32f22ee5aa6ce18897f74855dbb5fc0c8404d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:53:13 +0000 Subject: [PATCH 3/8] refactor(logging): Scope typed context at agent boundary Co-Authored-By: David Cramer --- packages/junior/src/chat/agent/index.ts | 77 +++++++++++------ packages/junior/src/chat/log-context.ts | 85 +++++++++++++++---- packages/junior/src/chat/logging.ts | 68 ++------------- .../tests/unit/logging/log-context.test.ts | 51 ++++++----- 4 files changed, 156 insertions(+), 125 deletions(-) diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 2015c46c8..31a83c564 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -29,6 +29,7 @@ import { type LogContext, } from "@/chat/logging"; import { getConfigDefaults } from "@/chat/configuration/defaults"; +import { runWithLogContext } from "@/chat/log-context"; import { SkillSandbox } from "@/chat/sandbox/skill-sandbox"; import { findSkillByName, @@ -185,8 +186,41 @@ export async function executeAgentRun( request.routing.destinationVisibility ?? request.routing.slackConversation?.visibility, }); - return runWithConversationPrivacy(conversationPrivacy ?? "private", () => - executeAgentRunInPrivacyContext(request, conversationPrivacy), + const credentialActor = request.routing.credentialContext?.actor; + const actor = actorFromRouting(request.routing); + const userActor = actor && "userId" in actor ? actor : undefined; + return runWithLogContext( + { + conversationId: request.conversationId, + platform: request.routing.source.platform, + messageConversationId: + request.routing.source.platform === "slack" + ? request.conversationId + : request.routing.source.conversationId, + destinationName: + request.routing.destination.platform === "slack" + ? request.routing.destination.channelId + : request.routing.destination.conversationId, + userId: userActor?.userId, + userName: userActor?.userName, + userEmail: userActor?.email, + runId: request.runId, + actorType: credentialActor + ? "type" in credentialActor + ? credentialActor.type + : "system" + : undefined, + actorId: credentialActor + ? "type" in credentialActor + ? credentialActor.userId + : credentialActor.name + : undefined, + assistantUserName: botConfig.userName, + }, + () => + runWithConversationPrivacy(conversationPrivacy ?? "private", () => + executeAgentRunInPrivacyContext(request, conversationPrivacy), + ), ); } @@ -292,16 +326,7 @@ async function executeAgentRunInPrivacyContext( activeModelProfile = projection.modelProfile; activeModelId = modelIdForProfile(botConfig, activeModelProfile); const shouldTrace = shouldEmitDevAgentTrace(); - const spanContext: LogContext = { - conversationId, - messageConversationId: slackSource ? conversationId : undefined, - userId: slackActor?.userId, - destinationName: slackDestination?.channelId, - runId, - ...credentialActorLogContext, - assistantUserName: botConfig.userName, - modelId: activeModelId, - }; + const spanContext: LogContext = { modelId: activeModelId }; // ── Skill discovery ────────────────────────────────────────────── const availableSkills = await discoverRunSkills({ @@ -592,11 +617,18 @@ async function executeAgentRunInPrivacyContext( }; setTags({ - conversationId: spanContext.conversationId, - messageConversationId: spanContext.messageConversationId, - userId: spanContext.userId, - destinationName: spanContext.destinationName, - runId: spanContext.runId, + conversationId, + platform: runSource.platform, + messageConversationId: + runSource.platform === "slack" ? conversationId : runSource.conversationId, + destinationName: + routing.destination.platform === "slack" + ? routing.destination.channelId + : routing.destination.conversationId, + userId: actor && "userId" in actor ? actor.userId : undefined, + userName: actor && "userId" in actor ? actor.userName : undefined, + userEmail: actor && "userId" in actor ? actor.email : undefined, + runId, ...credentialActorLogContext, assistantUserName: botConfig.userName, modelId: activeModelId, @@ -1195,16 +1227,7 @@ async function executeAgentRunInPrivacyContext( logException( error, "assistant_reply_generation_failed", - { - conversationId, - messageConversationId: slackSource ? conversationId : undefined, - userId: slackActor?.userId, - destinationName: slackDestination?.channelId, - runId, - ...credentialActorLogContext, - assistantUserName: botConfig.userName, - modelId: activeModelId, - }, + { modelId: activeModelId }, {}, "executeAgentRun failed", ); diff --git a/packages/junior/src/chat/log-context.ts b/packages/junior/src/chat/log-context.ts index 76f073ded..3c66c07af 100644 --- a/packages/junior/src/chat/log-context.ts +++ b/packages/junior/src/chat/log-context.ts @@ -3,35 +3,86 @@ import { AsyncLocalStorage } from "node:async_hooks"; export type LogAttributeValue = string | number | boolean | string[]; export type LogAttributes = Record; -/** Async context domain inherited by logs and spans in the current operation. */ +/** Provider-neutral correlation data inherited by logs and spans in an operation. */ +export interface LogContext { + conversationId?: string; + platform?: string; + requestId?: string; + messageConversationId?: string; + destinationName?: string; + userId?: string; + userName?: string; + userEmail?: string; + runId?: string; + actorType?: string; + actorId?: string; + assistantUserName?: string; + modelId?: string; + skillName?: string; + httpMethod?: string; + httpPath?: string; + urlFull?: string; + userAgent?: string; +} + +/** Async context domain consumed directly by LogTape. */ export const logContextStorage = new AsyncLocalStorage(); -function mergeLogAttributes( - current: LogAttributes | undefined, - next: LogAttributes, +function definedAttributes( + attributes: Record, ): LogAttributes { - return { ...current, ...next }; + return Object.fromEntries( + Object.entries(attributes).filter( + (entry): entry is [string, LogAttributeValue] => + typeof entry[1] === "string" || + typeof entry[1] === "number" || + typeof entry[1] === "boolean" || + (Array.isArray(entry[1]) && + entry[1].every((value) => typeof value === "string")), + ), + ); +} + +/** Convert provider-neutral domain context to stable telemetry attributes. */ +export function logContextToAttributes(context: LogContext): LogAttributes { + return definedAttributes({ + "gen_ai.conversation.id": context.conversationId, + "app.platform": context.platform, + "app.request.id": context.requestId, + "messaging.system": + context.platform === "slack" ? "slack" : context.platform, + "messaging.message.conversation_id": context.messageConversationId, + "messaging.destination.name": context.destinationName, + "enduser.id": context.userId, + "enduser.pseudo.id": context.userName, + "app.run.id": context.runId, + "app.actor.type": context.actorType, + "app.actor.id": context.actorId, + "gen_ai.agent.name": context.assistantUserName, + "gen_ai.request.model": context.modelId, + "app.skill.name": context.skillName, + "http.request.method": context.httpMethod, + "url.path": context.httpPath, + "url.full": context.urlFull, + "user_agent.original": context.userAgent, + }); } -/** Run an operation with additional log attributes without changing its callers. */ -export function bindLogAttributes( - attributes: LogAttributes, +/** Run an operation with merged context, restoring its parent on completion. */ +export function runWithLogContext( + context: LogContext, callback: () => T, ): T { return logContextStorage.run( - mergeLogAttributes(logContextStorage.getStore(), attributes), + { + ...logContextStorage.getStore(), + ...logContextToAttributes(context), + }, callback, ); } -/** Add log attributes to the current async operation. */ -export function extendLogAttributes(attributes: LogAttributes): void { - logContextStorage.enterWith( - mergeLogAttributes(logContextStorage.getStore(), attributes), - ); -} - -/** Read the attributes bound to the current async operation. */ +/** Read the attributes bound to the current operation. */ export function getBoundLogAttributes(): LogAttributes { return logContextStorage.getStore() ?? {}; } diff --git a/packages/junior/src/chat/logging.ts b/packages/junior/src/chat/logging.ts index cfcd3b258..379c4be30 100644 --- a/packages/junior/src/chat/logging.ts +++ b/packages/junior/src/chat/logging.ts @@ -15,11 +15,12 @@ import type { } from "chat"; import { toOptionalNumber, toOptionalString } from "@/chat/coerce"; import { - bindLogAttributes, - extendLogAttributes, getBoundLogAttributes, logContextStorage, + logContextToAttributes, + runWithLogContext, type LogAttributes, + type LogContext, } from "@/chat/log-context"; import { normalizeIdentityEmail } from "@/chat/identities/identity"; import { getActiveSpan } from "@/chat/sentry"; @@ -29,7 +30,7 @@ import { getDeploymentTelemetryAttributes } from "@/deployment"; type Primitive = string | number | boolean; type AttributeValue = Primitive | string[]; -export type { LogAttributes } from "@/chat/log-context"; +export type { LogAttributes, LogContext } from "@/chat/log-context"; export type LogLevel = "debug" | "info" | "warn" | "error"; export interface EmittedLogRecord { attributes: LogAttributes; @@ -38,27 +39,6 @@ export interface EmittedLogRecord { level: LogLevel; } -export interface LogContext { - conversationId?: string; - platform?: string; - requestId?: string; - messageConversationId?: string; - destinationName?: string; - userId?: string; - userName?: string; - userEmail?: string; - runId?: string; - actorType?: string; - actorId?: string; - assistantUserName?: string; - modelId?: string; - skillName?: string; - httpMethod?: string; - httpPath?: string; - urlFull?: string; - userAgent?: string; -} - export type TracePropagationHeaders = Partial< Record<"baggage" | "sentry-trace" | "traceparent", string> >; @@ -430,36 +410,7 @@ function sanitizeValue(value: unknown): AttributeValue | undefined { return sanitizePrimitive(value); } -function contextToAttributes(context: LogContext): LogAttributes { - const attributes: Record = { - "gen_ai.conversation.id": context.conversationId, - "app.platform": context.platform, - "app.request.id": context.requestId, - "messaging.system": - context.platform === "slack" ? "slack" : context.platform, - "messaging.message.conversation_id": context.messageConversationId, - "messaging.destination.name": context.destinationName, - "enduser.id": context.userId, - "enduser.pseudo.id": context.userName, - "app.run.id": context.runId, - "app.actor.type": context.actorType, - "app.actor.id": context.actorId, - "gen_ai.agent.name": context.assistantUserName, - "gen_ai.request.model": context.modelId, - "app.skill.name": context.skillName, - "http.request.method": context.httpMethod, - "url.path": context.httpPath, - "url.full": context.urlFull, - "user_agent.original": context.userAgent, - }; - - const normalized: LogAttributes = {}; - for (const [key, value] of Object.entries(attributes)) { - const sanitized = sanitizeValue(value); - if (sanitized !== undefined) normalized[key] = sanitized; - } - return normalized; -} +const contextToAttributes = logContextToAttributes; function getTraceCorrelationAttributes(): LogAttributes { const sentry = Sentry as unknown as SentryLike; @@ -1398,11 +1349,7 @@ export function withLogContext( context: LogContext, callback: () => Promise, ): Promise { - return bindLogAttributes(contextToAttributes(context), callback); -} - -export function setLogContext(context: LogContext): void { - extendLogAttributes(contextToAttributes(context)); + return runWithLogContext(context, callback); } export function getLogContextAttributes(): LogAttributes { @@ -1645,9 +1592,8 @@ export function logException( ); } -/** Set log context and Sentry scope metadata for the current request. */ +/** Set Sentry scope metadata; async log context is bound at operation boundaries. */ export function setTags(context: LogContext = {}): void { - setLogContext(context); setSentryTagsFromContext(context); setSentryUser(sentryUserIdentityFromContext(context)); } diff --git a/packages/junior/tests/unit/logging/log-context.test.ts b/packages/junior/tests/unit/logging/log-context.test.ts index 65a61c415..2afe90167 100644 --- a/packages/junior/tests/unit/logging/log-context.test.ts +++ b/packages/junior/tests/unit/logging/log-context.test.ts @@ -1,40 +1,51 @@ import { describe, expect, it } from "vitest"; import { - bindLogAttributes, - extendLogAttributes, getBoundLogAttributes, + runWithLogContext, } from "@/chat/log-context"; describe("log context", () => { - it("inherits and restores nested async context", async () => { + it("maps typed context and restores nested async scopes", async () => { expect(getBoundLogAttributes()).toEqual({}); - await bindLogAttributes({ "app.request.id": "outer" }, async () => { - expect(getBoundLogAttributes()).toEqual({ "app.request.id": "outer" }); - - await bindLogAttributes({ "app.run.id": "inner" }, async () => { - await Promise.resolve(); + await runWithLogContext( + { requestId: "outer", destinationName: "channel" }, + async () => { expect(getBoundLogAttributes()).toEqual({ "app.request.id": "outer", - "app.run.id": "inner", + "messaging.destination.name": "channel", + }); + + await runWithLogContext({ runId: "inner" }, async () => { + await Promise.resolve(); + expect(getBoundLogAttributes()).toEqual({ + "app.request.id": "outer", + "app.run.id": "inner", + "messaging.destination.name": "channel", + }); }); - }); - expect(getBoundLogAttributes()).toEqual({ "app.request.id": "outer" }); - }); + expect(getBoundLogAttributes()).toEqual({ + "app.request.id": "outer", + "messaging.destination.name": "channel", + }); + }, + ); expect(getBoundLogAttributes()).toEqual({}); }); - it("extends only the current async context", async () => { - await bindLogAttributes({ "app.request.id": "request" }, async () => { - extendLogAttributes({ "app.run.id": "run" }); - expect(getBoundLogAttributes()).toEqual({ - "app.request.id": "request", - "app.run.id": "run", - }); - }); + it("isolates concurrent operations", async () => { + const seen = await Promise.all( + ["first", "second"].map((runId) => + runWithLogContext({ runId }, async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return getBoundLogAttributes()["app.run.id"]; + }), + ), + ); + expect(seen).toEqual(["first", "second"]); expect(getBoundLogAttributes()).toEqual({}); }); }); From 575278d0f610de98f537f3f4c13bd3e8c666da69 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:02:06 +0000 Subject: [PATCH 4/8] fix(logging): Preserve scoped context updates Co-Authored-By: David Cramer --- packages/junior/src/chat/agent/index.ts | 8 +++-- packages/junior/src/chat/log-context.ts | 19 +++++++----- packages/junior/src/chat/logging.ts | 12 ++++--- .../tests/unit/logging/log-context.test.ts | 31 +++++++++++++++---- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 31a83c564..7d80525fe 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -25,11 +25,11 @@ import { setSpanAttributes, setTags, summarizeMessageText, + withLogContext, withSpan, type LogContext, } from "@/chat/logging"; import { getConfigDefaults } from "@/chat/configuration/defaults"; -import { runWithLogContext } from "@/chat/log-context"; import { SkillSandbox } from "@/chat/sandbox/skill-sandbox"; import { findSkillByName, @@ -189,7 +189,7 @@ export async function executeAgentRun( const credentialActor = request.routing.credentialContext?.actor; const actor = actorFromRouting(request.routing); const userActor = actor && "userId" in actor ? actor : undefined; - return runWithLogContext( + return withLogContext( { conversationId: request.conversationId, platform: request.routing.source.platform, @@ -620,7 +620,9 @@ async function executeAgentRunInPrivacyContext( conversationId, platform: runSource.platform, messageConversationId: - runSource.platform === "slack" ? conversationId : runSource.conversationId, + runSource.platform === "slack" + ? conversationId + : runSource.conversationId, destinationName: routing.destination.platform === "slack" ? routing.destination.channelId diff --git a/packages/junior/src/chat/log-context.ts b/packages/junior/src/chat/log-context.ts index 3c66c07af..6b5b54141 100644 --- a/packages/junior/src/chat/log-context.ts +++ b/packages/junior/src/chat/log-context.ts @@ -68,20 +68,25 @@ export function logContextToAttributes(context: LogContext): LogAttributes { }); } -/** Run an operation with merged context, restoring its parent on completion. */ -export function runWithLogContext( - context: LogContext, +/** Run an operation with merged attributes, restoring its parent on completion. */ +export function runWithLogAttributes( + attributes: LogAttributes, callback: () => T, ): T { return logContextStorage.run( - { - ...logContextStorage.getStore(), - ...logContextToAttributes(context), - }, + { ...logContextStorage.getStore(), ...attributes }, callback, ); } +/** Merge attributes into the current scoped operation. */ +export function updateLogAttributes(attributes: LogAttributes): void { + const current = logContextStorage.getStore(); + if (current) { + Object.assign(current, attributes); + } +} + /** Read the attributes bound to the current operation. */ export function getBoundLogAttributes(): LogAttributes { return logContextStorage.getStore() ?? {}; diff --git a/packages/junior/src/chat/logging.ts b/packages/junior/src/chat/logging.ts index 379c4be30..0f90ff518 100644 --- a/packages/junior/src/chat/logging.ts +++ b/packages/junior/src/chat/logging.ts @@ -18,7 +18,8 @@ import { getBoundLogAttributes, logContextStorage, logContextToAttributes, - runWithLogContext, + runWithLogAttributes, + updateLogAttributes, type LogAttributes, type LogContext, } from "@/chat/log-context"; @@ -410,7 +411,9 @@ function sanitizeValue(value: unknown): AttributeValue | undefined { return sanitizePrimitive(value); } -const contextToAttributes = logContextToAttributes; +function contextToAttributes(context: LogContext): LogAttributes { + return mergeAttributes(logContextToAttributes(context)); +} function getTraceCorrelationAttributes(): LogAttributes { const sentry = Sentry as unknown as SentryLike; @@ -1349,7 +1352,7 @@ export function withLogContext( context: LogContext, callback: () => Promise, ): Promise { - return runWithLogContext(context, callback); + return runWithLogAttributes(contextToAttributes(context), callback); } export function getLogContextAttributes(): LogAttributes { @@ -1592,8 +1595,9 @@ export function logException( ); } -/** Set Sentry scope metadata; async log context is bound at operation boundaries. */ +/** Add context to the current operation and Sentry scope. */ export function setTags(context: LogContext = {}): void { + updateLogAttributes(contextToAttributes(context)); setSentryTagsFromContext(context); setSentryUser(sentryUserIdentityFromContext(context)); } diff --git a/packages/junior/tests/unit/logging/log-context.test.ts b/packages/junior/tests/unit/logging/log-context.test.ts index 2afe90167..a87a2e212 100644 --- a/packages/junior/tests/unit/logging/log-context.test.ts +++ b/packages/junior/tests/unit/logging/log-context.test.ts @@ -1,22 +1,26 @@ import { describe, expect, it } from "vitest"; import { getBoundLogAttributes, - runWithLogContext, + runWithLogAttributes, + updateLogAttributes, } from "@/chat/log-context"; describe("log context", () => { - it("maps typed context and restores nested async scopes", async () => { + it("restores nested async scopes", async () => { expect(getBoundLogAttributes()).toEqual({}); - await runWithLogContext( - { requestId: "outer", destinationName: "channel" }, + await runWithLogAttributes( + { + "app.request.id": "outer", + "messaging.destination.name": "channel", + }, async () => { expect(getBoundLogAttributes()).toEqual({ "app.request.id": "outer", "messaging.destination.name": "channel", }); - await runWithLogContext({ runId: "inner" }, async () => { + await runWithLogAttributes({ "app.run.id": "inner" }, async () => { await Promise.resolve(); expect(getBoundLogAttributes()).toEqual({ "app.request.id": "outer", @@ -38,7 +42,7 @@ describe("log context", () => { it("isolates concurrent operations", async () => { const seen = await Promise.all( ["first", "second"].map((runId) => - runWithLogContext({ runId }, async () => { + runWithLogAttributes({ "app.run.id": runId }, async () => { await new Promise((resolve) => setTimeout(resolve, 0)); return getBoundLogAttributes()["app.run.id"]; }), @@ -48,4 +52,19 @@ describe("log context", () => { expect(seen).toEqual(["first", "second"]); expect(getBoundLogAttributes()).toEqual({}); }); + + it("updates only an existing scoped operation", async () => { + updateLogAttributes({ "app.run.id": "ignored" }); + expect(getBoundLogAttributes()).toEqual({}); + + await runWithLogAttributes({ "app.request.id": "request" }, async () => { + updateLogAttributes({ "app.run.id": "run" }); + expect(getBoundLogAttributes()).toEqual({ + "app.request.id": "request", + "app.run.id": "run", + }); + }); + + expect(getBoundLogAttributes()).toEqual({}); + }); }); From a754e43502c8f5c220c3ec9f602e50eb4bb4d20b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 22 Jul 2026 17:46:52 -0700 Subject: [PATCH 5/8] fix(logging): Bind context from current run contract Drop the removed resume correlation field and cover scoped updates, sanitization, and startup-failure correlation after rebasing. --- .../junior/src/chat/runtime/slack-resume.ts | 1 - .../runtime/agent-run-error-path.test.ts | 40 +++++++++++++++++++ .../tests/unit/logging/sentry-context.test.ts | 23 +++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index e92a1a678..98cca555f 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -224,7 +224,6 @@ function getResumeLogContext( userId: isUserActor(actor) ? actor.userId : undefined, userName: isUserActor(actor) ? actor.userName : undefined, destinationName: args.channelId, - runId: routing?.correlation?.runId, assistantUserName: botConfig.userName, modelId: standardModelId(botConfig), }; diff --git a/packages/junior/tests/component/runtime/agent-run-error-path.test.ts b/packages/junior/tests/component/runtime/agent-run-error-path.test.ts index 5e8b40ab6..5eca29f06 100644 --- a/packages/junior/tests/component/runtime/agent-run-error-path.test.ts +++ b/packages/junior/tests/component/runtime/agent-run-error-path.test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, it, vi } from "vitest"; import { createLocalSource } from "@sentry/junior-plugin-api"; +import { registerLogRecordSink, type EmittedLogRecord } from "@/chat/logging"; const originalAiModel = process.env.AI_MODEL; @@ -57,6 +58,45 @@ describe("executeAgentRun error path", () => { expect(reply!.diagnostics.reasoningLevel).toBeUndefined(); }); + it("binds authoritative request context before startup failures", async () => { + const records: EmittedLogRecord[] = []; + const unregister = registerLogRecordSink((record) => records.push(record)); + + try { + await executeAgentRun({ + conversationId: LOCAL_DESTINATION.conversationId, + turnId: "turn-context-failure", + runId: "run-context-failure", + input: { messageText: "hello" }, + routing: { + actor: { + platform: "local", + userId: "local-user", + userName: "alice", + }, + destination: LOCAL_DESTINATION, + source: LOCAL_SOURCE, + }, + }); + } finally { + unregister(); + } + + const failure = records.find( + (record) => record.eventName === "assistant_reply_generation_failed", + ); + expect(failure?.attributes).toMatchObject({ + "app.platform": "local", + "app.run.id": "run-context-failure", + "enduser.id": "local-user", + "enduser.pseudo.id": "alice", + "gen_ai.conversation.id": LOCAL_DESTINATION.conversationId, + "messaging.destination.name": LOCAL_DESTINATION.conversationId, + "messaging.message.conversation_id": LOCAL_DESTINATION.conversationId, + "messaging.system": "local", + }); + }); + it("preserves configured reasoning in failure diagnostics", async () => { const outcome = await executeAgentRun({ conversationId: LOCAL_DESTINATION.conversationId, diff --git a/packages/junior/tests/unit/logging/sentry-context.test.ts b/packages/junior/tests/unit/logging/sentry-context.test.ts index 6d099f48a..f0b1f155d 100644 --- a/packages/junior/tests/unit/logging/sentry-context.test.ts +++ b/packages/junior/tests/unit/logging/sentry-context.test.ts @@ -31,6 +31,29 @@ afterEach(() => { }); describe("Sentry context", () => { + it("extends only the active sanitized log context", async () => { + const { getLogContextAttributes, setTags, withLogContext } = + await import("@/chat/logging"); + + setTags({ runId: "outside" }); + expect(getLogContextAttributes()).toEqual({}); + + await withLogContext({ conversationId: "conversation" }, async () => { + setTags({ + destinationName: "Bearer abcdefghijklmnopqrstuvwxyz", + runId: "run", + }); + + expect(getLogContextAttributes()).toEqual({ + "app.run.id": "run", + "gen_ai.conversation.id": "conversation", + "messaging.destination.name": "Bearer abcd...wxyz", + }); + }); + + expect(getLogContextAttributes()).toEqual({}); + }); + it("uses native user identity and a small tag allowlist", async () => { const { setTags } = await import("@/chat/logging"); From cd752ef16bddbc08725cf39589b7af76a090e140 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 22 Jul 2026 17:58:38 -0700 Subject: [PATCH 6/8] ref(logging): Reuse agent run context projection Build the provider-neutral run context once for async binding and Sentry publication, and narrow its typed projection to optional strings. --- packages/junior/src/chat/agent/index.ts | 83 +++++++++++-------------- packages/junior/src/chat/log-context.ts | 9 +-- 2 files changed, 38 insertions(+), 54 deletions(-) diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 7d80525fe..4b493da82 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -189,44 +189,48 @@ export async function executeAgentRun( const credentialActor = request.routing.credentialContext?.actor; const actor = actorFromRouting(request.routing); const userActor = actor && "userId" in actor ? actor : undefined; - return withLogContext( - { - conversationId: request.conversationId, - platform: request.routing.source.platform, - messageConversationId: - request.routing.source.platform === "slack" - ? request.conversationId - : request.routing.source.conversationId, - destinationName: - request.routing.destination.platform === "slack" - ? request.routing.destination.channelId - : request.routing.destination.conversationId, - userId: userActor?.userId, - userName: userActor?.userName, - userEmail: userActor?.email, - runId: request.runId, - actorType: credentialActor - ? "type" in credentialActor - ? credentialActor.type - : "system" - : undefined, - actorId: credentialActor - ? "type" in credentialActor - ? credentialActor.userId - : credentialActor.name - : undefined, - assistantUserName: botConfig.userName, - }, - () => - runWithConversationPrivacy(conversationPrivacy ?? "private", () => - executeAgentRunInPrivacyContext(request, conversationPrivacy), + const runLogContext: LogContext = { + conversationId: request.conversationId, + platform: request.routing.source.platform, + messageConversationId: + request.routing.source.platform === "slack" + ? request.conversationId + : request.routing.source.conversationId, + destinationName: + request.routing.destination.platform === "slack" + ? request.routing.destination.channelId + : request.routing.destination.conversationId, + userId: userActor?.userId, + userName: userActor?.userName, + userEmail: userActor?.email, + runId: request.runId, + actorType: credentialActor + ? "type" in credentialActor + ? credentialActor.type + : "system" + : undefined, + actorId: credentialActor + ? "type" in credentialActor + ? credentialActor.userId + : credentialActor.name + : undefined, + assistantUserName: botConfig.userName, + }; + return withLogContext(runLogContext, () => + runWithConversationPrivacy(conversationPrivacy ?? "private", () => + executeAgentRunInPrivacyContext( + request, + conversationPrivacy, + runLogContext, ), + ), ); } async function executeAgentRunInPrivacyContext( request: AgentRunRequest, conversationPrivacy: ConversationPrivacy | undefined, + runLogContext: LogContext, ): Promise { const { conversationId, input, routing, runId, turnId } = request; const policy = request.policy ?? {}; @@ -617,22 +621,7 @@ async function executeAgentRunInPrivacyContext( }; setTags({ - conversationId, - platform: runSource.platform, - messageConversationId: - runSource.platform === "slack" - ? conversationId - : runSource.conversationId, - destinationName: - routing.destination.platform === "slack" - ? routing.destination.channelId - : routing.destination.conversationId, - userId: actor && "userId" in actor ? actor.userId : undefined, - userName: actor && "userId" in actor ? actor.userName : undefined, - userEmail: actor && "userId" in actor ? actor.email : undefined, - runId, - ...credentialActorLogContext, - assistantUserName: botConfig.userName, + ...runLogContext, modelId: activeModelId, }); diff --git a/packages/junior/src/chat/log-context.ts b/packages/junior/src/chat/log-context.ts index 6b5b54141..4c4c6960a 100644 --- a/packages/junior/src/chat/log-context.ts +++ b/packages/junior/src/chat/log-context.ts @@ -29,16 +29,11 @@ export interface LogContext { export const logContextStorage = new AsyncLocalStorage(); function definedAttributes( - attributes: Record, + attributes: Record, ): LogAttributes { return Object.fromEntries( Object.entries(attributes).filter( - (entry): entry is [string, LogAttributeValue] => - typeof entry[1] === "string" || - typeof entry[1] === "number" || - typeof entry[1] === "boolean" || - (Array.isArray(entry[1]) && - entry[1].every((value) => typeof value === "string")), + (entry): entry is [string, string] => entry[1] !== undefined, ), ); } From 05b869e4fe0158f7732eaaf01d64a3c1b1cdf4f8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:12:27 +0000 Subject: [PATCH 7/8] fix(logging): Preserve bound Sentry scope context Co-Authored-By: David Cramer --- packages/junior/src/chat/log-context.ts | 45 +++++++++++++++++-- packages/junior/src/chat/logging.ts | 29 +++++++----- .../tests/unit/logging/sentry-context.test.ts | 36 +++++++++++---- 3 files changed, 89 insertions(+), 21 deletions(-) diff --git a/packages/junior/src/chat/log-context.ts b/packages/junior/src/chat/log-context.ts index 4c4c6960a..9f5c97a07 100644 --- a/packages/junior/src/chat/log-context.ts +++ b/packages/junior/src/chat/log-context.ts @@ -25,9 +25,12 @@ export interface LogContext { userAgent?: string; } -/** Async context domain consumed directly by LogTape. */ +/** Async attribute domain consumed directly by LogTape. */ export const logContextStorage = new AsyncLocalStorage(); +/** Typed context domain retained for consumers such as native Sentry scope fields. */ +const typedLogContextStorage = new AsyncLocalStorage(); + function definedAttributes( attributes: Record, ): LogAttributes { @@ -63,7 +66,23 @@ export function logContextToAttributes(context: LogContext): LogAttributes { }); } -/** Run an operation with merged attributes, restoring its parent on completion. */ +/** Run an operation with merged context, restoring its parent on completion. */ +export function runWithLogContext( + context: LogContext, + attributes: LogAttributes, + callback: () => T, +): T { + return typedLogContextStorage.run( + { ...typedLogContextStorage.getStore(), ...context }, + () => + logContextStorage.run( + { ...logContextStorage.getStore(), ...attributes }, + callback, + ), + ); +} + +/** Run an operation with raw attributes, restoring its parent on completion. */ export function runWithLogAttributes( attributes: LogAttributes, callback: () => T, @@ -74,7 +93,7 @@ export function runWithLogAttributes( ); } -/** Merge attributes into the current scoped operation. */ +/** Merge raw attributes into the current scoped operation. */ export function updateLogAttributes(attributes: LogAttributes): void { const current = logContextStorage.getStore(); if (current) { @@ -82,6 +101,26 @@ export function updateLogAttributes(attributes: LogAttributes): void { } } +/** Merge context and attributes into the current scoped operation. */ +export function updateLogContext( + context: LogContext, + attributes: LogAttributes, +): void { + const currentContext = typedLogContextStorage.getStore(); + if (currentContext) { + Object.assign(currentContext, context); + } + const currentAttributes = logContextStorage.getStore(); + if (currentAttributes) { + Object.assign(currentAttributes, attributes); + } +} + +/** Read the typed context bound to the current operation. */ +export function getBoundLogContext(): LogContext { + return typedLogContextStorage.getStore() ?? {}; +} + /** Read the attributes bound to the current operation. */ export function getBoundLogAttributes(): LogAttributes { return logContextStorage.getStore() ?? {}; diff --git a/packages/junior/src/chat/logging.ts b/packages/junior/src/chat/logging.ts index 0f90ff518..3406359d4 100644 --- a/packages/junior/src/chat/logging.ts +++ b/packages/junior/src/chat/logging.ts @@ -16,10 +16,11 @@ import type { import { toOptionalNumber, toOptionalString } from "@/chat/coerce"; import { getBoundLogAttributes, + getBoundLogContext, logContextStorage, logContextToAttributes, - runWithLogAttributes, - updateLogAttributes, + runWithLogContext as runWithScopedLogContext, + updateLogContext, type LogAttributes, type LogContext, } from "@/chat/log-context"; @@ -1213,9 +1214,10 @@ export const log = { typeof sentryCaptureException === "function" ) { sentryWithScope((scope) => { - if (context) { - setSentryScopeContext(scope, context); - } + setSentryScopeContext(scope, { + ...getBoundLogContext(), + ...context, + }); for (const [key, value] of Object.entries( mergeAttributes(getBoundLogAttributes(), attrs), )) { @@ -1227,9 +1229,12 @@ export const log = { } if (typeof sentryCaptureException === "function") { - if (context) { - setSentryUser(sentryUserIdentityFromContext(context)); - } + setSentryUser( + sentryUserIdentityFromContext({ + ...getBoundLogContext(), + ...context, + }), + ); eventId = sentryCaptureException(normalizedError); } return eventId; @@ -1352,7 +1357,11 @@ export function withLogContext( context: LogContext, callback: () => Promise, ): Promise { - return runWithLogAttributes(contextToAttributes(context), callback); + return runWithScopedLogContext( + context, + contextToAttributes(context), + callback, + ); } export function getLogContextAttributes(): LogAttributes { @@ -1597,7 +1606,7 @@ export function logException( /** Add context to the current operation and Sentry scope. */ export function setTags(context: LogContext = {}): void { - updateLogAttributes(contextToAttributes(context)); + updateLogContext(context, contextToAttributes(context)); setSentryTagsFromContext(context); setSentryUser(sentryUserIdentityFromContext(context)); } diff --git a/packages/junior/tests/unit/logging/sentry-context.test.ts b/packages/junior/tests/unit/logging/sentry-context.test.ts index f0b1f155d..955796b21 100644 --- a/packages/junior/tests/unit/logging/sentry-context.test.ts +++ b/packages/junior/tests/unit/logging/sentry-context.test.ts @@ -148,21 +148,29 @@ describe("Sentry context", () => { ); }); - it("applies native user identity when capturing exceptions with context", async () => { + it("applies bound and local context when capturing exceptions", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); - const { logException } = await import("@/chat/logging"); + const { logException, setTags, withLogContext } = + await import("@/chat/logging"); - const eventId = logException( - new Error("boom"), - "turn_failed", + const eventId = await withLogContext( { + conversationId: "thread_123", + platform: "slack", userId: "U123", userName: "alice", userEmail: "Alice@Example.COM", - modelId: "openai/gpt-5.4", }, - {}, - "Turn failed", + async () => { + setTags({ assistantUserName: "junior" }); + return logException( + new Error("boom"), + "turn_failed", + { modelId: "openai/gpt-5.4" }, + {}, + "Turn failed", + ); + }, ); expect(eventId).toBe("event-id"); @@ -176,6 +184,18 @@ describe("Sentry context", () => { "gen_ai.request.model", "openai/gpt-5.4", ); + expect(sentry.scope.setTag).toHaveBeenCalledWith( + "gen_ai.agent.name", + "junior", + ); + expect(sentry.scope.setContext).toHaveBeenCalledWith( + "app", + expect.objectContaining({ + "gen_ai.conversation.id": "thread_123", + "gen_ai.request.model": "openai/gpt-5.4", + "messaging.system": "slack", + }), + ); expect(sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); }); }); From 3a0de0c65f0d4554e073bdb40b28d0cc163d1b5d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 22 Jul 2026 18:20:49 -0700 Subject: [PATCH 8/8] fix(logging): Preserve inherited typed context Ignore undefined typed context deltas so nested scopes retain parent values just like the emitted attribute store. --- packages/junior/src/chat/log-context.ts | 13 ++++++++-- .../tests/unit/logging/log-context.test.ts | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/junior/src/chat/log-context.ts b/packages/junior/src/chat/log-context.ts index 9f5c97a07..c8be01e2a 100644 --- a/packages/junior/src/chat/log-context.ts +++ b/packages/junior/src/chat/log-context.ts @@ -31,6 +31,12 @@ export const logContextStorage = new AsyncLocalStorage(); /** Typed context domain retained for consumers such as native Sentry scope fields. */ const typedLogContextStorage = new AsyncLocalStorage(); +function definedLogContext(context: LogContext): LogContext { + return Object.fromEntries( + Object.entries(context).filter(([, value]) => value !== undefined), + ) as LogContext; +} + function definedAttributes( attributes: Record, ): LogAttributes { @@ -73,7 +79,10 @@ export function runWithLogContext( callback: () => T, ): T { return typedLogContextStorage.run( - { ...typedLogContextStorage.getStore(), ...context }, + { + ...typedLogContextStorage.getStore(), + ...definedLogContext(context), + }, () => logContextStorage.run( { ...logContextStorage.getStore(), ...attributes }, @@ -108,7 +117,7 @@ export function updateLogContext( ): void { const currentContext = typedLogContextStorage.getStore(); if (currentContext) { - Object.assign(currentContext, context); + Object.assign(currentContext, definedLogContext(context)); } const currentAttributes = logContextStorage.getStore(); if (currentAttributes) { diff --git a/packages/junior/tests/unit/logging/log-context.test.ts b/packages/junior/tests/unit/logging/log-context.test.ts index a87a2e212..b665c3b13 100644 --- a/packages/junior/tests/unit/logging/log-context.test.ts +++ b/packages/junior/tests/unit/logging/log-context.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { getBoundLogAttributes, + getBoundLogContext, runWithLogAttributes, + runWithLogContext, updateLogAttributes, } from "@/chat/log-context"; @@ -39,6 +41,28 @@ describe("log context", () => { expect(getBoundLogAttributes()).toEqual({}); }); + it("inherits defined typed context through nested scopes", async () => { + await runWithLogContext( + { conversationId: "conversation", modelId: "outer-model" }, + {}, + async () => { + await runWithLogContext( + { modelId: undefined, runId: "inner-run" }, + {}, + async () => { + expect(getBoundLogContext()).toEqual({ + conversationId: "conversation", + modelId: "outer-model", + runId: "inner-run", + }); + }, + ); + }, + ); + + expect(getBoundLogContext()).toEqual({}); + }); + it("isolates concurrent operations", async () => { const seen = await Promise.all( ["first", "second"].map((runId) =>