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..4b493da82 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -25,6 +25,7 @@ import { setSpanAttributes, setTags, summarizeMessageText, + withLogContext, withSpan, type LogContext, } from "@/chat/logging"; @@ -185,14 +186,51 @@ 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; + 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 ?? {}; @@ -292,16 +330,7 @@ async function executeAgentRunInPrivacyContext( activeModelProfile = projection.modelProfile; activeModelId = modelIdForProfile(botConfig, activeModelProfile); const shouldTrace = shouldEmitDevAgentTrace(); - const spanContext: LogContext = { - conversationId, - slackThreadId: slackSource ? conversationId : undefined, - slackUserId: slackActor?.userId, - slackChannelId: slackDestination?.channelId, - runId, - ...credentialActorLogContext, - assistantUserName: botConfig.userName, - modelId: activeModelId, - }; + const spanContext: LogContext = { modelId: activeModelId }; // ── Skill discovery ────────────────────────────────────────────── const availableSkills = await discoverRunSkills({ @@ -592,13 +621,7 @@ async function executeAgentRunInPrivacyContext( }; setTags({ - conversationId: spanContext.conversationId, - slackThreadId: slackSource ? conversationId : undefined, - slackUserId: slackActor?.userId, - slackChannelId: slackDestination?.channelId, - runId, - ...credentialActorLogContext, - assistantUserName: botConfig.userName, + ...runLogContext, modelId: activeModelId, }); @@ -1195,16 +1218,7 @@ async function executeAgentRunInPrivacyContext( logException( error, "assistant_reply_generation_failed", - { - conversationId, - slackThreadId: slackSource ? conversationId : undefined, - slackUserId: slackActor?.userId, - slackChannelId: slackDestination?.channelId, - runId, - ...credentialActorLogContext, - assistantUserName: botConfig.userName, - modelId: activeModelId, - }, + { modelId: activeModelId }, {}, "executeAgentRun failed", ); 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..c8be01e2a --- /dev/null +++ b/packages/junior/src/chat/log-context.ts @@ -0,0 +1,136 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export type LogAttributeValue = string | number | boolean | string[]; +export type LogAttributes = Record; + +/** 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 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 definedLogContext(context: LogContext): LogContext { + return Object.fromEntries( + Object.entries(context).filter(([, value]) => value !== undefined), + ) as LogContext; +} + +function definedAttributes( + attributes: Record, +): LogAttributes { + return Object.fromEntries( + Object.entries(attributes).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); +} + +/** 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 merged context, restoring its parent on completion. */ +export function runWithLogContext( + context: LogContext, + attributes: LogAttributes, + callback: () => T, +): T { + return typedLogContextStorage.run( + { + ...typedLogContextStorage.getStore(), + ...definedLogContext(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, +): T { + return logContextStorage.run( + { ...logContextStorage.getStore(), ...attributes }, + callback, + ); +} + +/** Merge raw attributes into the current scoped operation. */ +export function updateLogAttributes(attributes: LogAttributes): void { + const current = logContextStorage.getStore(); + if (current) { + Object.assign(current, attributes); + } +} + +/** 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, definedLogContext(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 af595830e..3406359d4 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,16 @@ import type { LogLevel as ChatSdkLogLevel, } from "chat"; import { toOptionalNumber, toOptionalString } from "@/chat/coerce"; +import { + getBoundLogAttributes, + getBoundLogContext, + logContextStorage, + logContextToAttributes, + runWithLogContext as runWithScopedLogContext, + updateLogContext, + type LogAttributes, + type LogContext, +} from "@/chat/log-context"; import { normalizeIdentityEmail } from "@/chat/identities/identity"; import { getActiveSpan } from "@/chat/sentry"; import * as Sentry from "@/chat/sentry"; @@ -23,7 +32,7 @@ import { getDeploymentTelemetryAttributes } from "@/deployment"; type Primitive = string | number | boolean; type AttributeValue = Primitive | string[]; -export type LogAttributes = Record; +export type { LogAttributes, LogContext } from "@/chat/log-context"; export type LogLevel = "debug" | "info" | "warn" | "error"; export interface EmittedLogRecord { attributes: LogAttributes; @@ -32,27 +41,6 @@ export interface EmittedLogRecord { level: LogLevel; } -export interface LogContext { - conversationId?: string; - platform?: string; - requestId?: string; - slackThreadId?: string; - slackUserId?: string; - slackUserName?: string; - slackUserEmail?: string; - slackChannelId?: 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> >; @@ -141,7 +129,6 @@ function normalizeGenAiFinishReasons(value: unknown): unknown { ); } -const contextStorage = new AsyncLocalStorage(); const logRecordSinks = new Set<(record: EmittedLogRecord) => void>(); const deploymentLogAttributes = getDeploymentTelemetryAttributes(); type ConsoleTextStyle = Parameters[0]; @@ -426,34 +413,7 @@ function sanitizeValue(value: unknown): AttributeValue | undefined { } 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.slackThreadId, - "messaging.destination.name": context.slackChannelId, - "enduser.id": context.slackUserId, - "enduser.pseudo.id": context.slackUserName, - "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; + return mergeAttributes(logContextToAttributes(context)); } function getTraceCorrelationAttributes(): LogAttributes { @@ -615,7 +575,7 @@ function ensureLoggerBackend(): void { lowestLevel: "error", }, ], - contextLocalStorage: contextStorage, + contextLocalStorage: logContextStorage, }); ownsLogTapeBackend = true; rootLogger = getLogger([...ROOT_LOGGER_CATEGORY]); @@ -1138,7 +1098,7 @@ function emitRecord( const source = getLogSource([...ROOT_LOGGER_CATEGORY, ...category]); const contextAttributes = ownsLogTapeBackend ? undefined - : contextStorage.getStore(); + : getBoundLogAttributes(); const attributes = mergeAttributes( contextAttributes, traceAttributes, @@ -1254,11 +1214,12 @@ 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(contextStorage.getStore(), attrs), + mergeAttributes(getBoundLogAttributes(), attrs), )) { scope.setExtra(key, value); } @@ -1268,9 +1229,12 @@ export const log = { } if (typeof sentryCaptureException === "function") { - if (context) { - setSentryUser(sentryUserIdentityFromContext(context)); - } + setSentryUser( + sentryUserIdentityFromContext({ + ...getBoundLogContext(), + ...context, + }), + ); eventId = sentryCaptureException(normalizedError); } return eventId; @@ -1393,23 +1357,15 @@ export function withLogContext( context: LogContext, callback: () => Promise, ): Promise { - const next = mergeAttributes( - contextStorage.getStore(), - contextToAttributes(context), - ); - return contextStorage.run(next, callback); -} - -export function setLogContext(context: LogContext): void { - const merged = mergeAttributes( - contextStorage.getStore(), + return runWithScopedLogContext( + context, contextToAttributes(context), + callback, ); - contextStorage.enterWith(merged); } export function getLogContextAttributes(): LogAttributes { - return contextStorage.getStore() ?? {}; + return getBoundLogAttributes(); } /** Return inherited log context filtered to attributes valid for the span operation. */ @@ -1479,11 +1435,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 } : {}), }; } @@ -1648,9 +1604,9 @@ export function logException( ); } -/** Set log context and Sentry scope metadata for the current request. */ +/** Add context to the current operation and Sentry scope. */ export function setTags(context: LogContext = {}): void { - setLogContext(context); + updateLogContext(context, contextToAttributes(context)); setSentryTagsFromContext(context); setSentryUser(sentryUserIdentityFromContext(context)); } 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..98cca555f 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -220,10 +220,10 @@ 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, 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/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/log-context.test.ts b/packages/junior/tests/unit/logging/log-context.test.ts new file mode 100644 index 000000000..b665c3b13 --- /dev/null +++ b/packages/junior/tests/unit/logging/log-context.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + getBoundLogAttributes, + getBoundLogContext, + runWithLogAttributes, + runWithLogContext, + updateLogAttributes, +} from "@/chat/log-context"; + +describe("log context", () => { + it("restores nested async scopes", async () => { + expect(getBoundLogAttributes()).toEqual({}); + + await runWithLogAttributes( + { + "app.request.id": "outer", + "messaging.destination.name": "channel", + }, + async () => { + expect(getBoundLogAttributes()).toEqual({ + "app.request.id": "outer", + "messaging.destination.name": "channel", + }); + + await runWithLogAttributes({ "app.run.id": "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", + "messaging.destination.name": "channel", + }); + }, + ); + + 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) => + runWithLogAttributes({ "app.run.id": runId }, async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return getBoundLogAttributes()["app.run.id"]; + }), + ), + ); + + 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({}); + }); +}); diff --git a/packages/junior/tests/unit/logging/sentry-context.test.ts b/packages/junior/tests/unit/logging/sentry-context.test.ts index 9d2e0b04a..955796b21 100644 --- a/packages/junior/tests/unit/logging/sentry-context.test.ts +++ b/packages/junior/tests/unit/logging/sentry-context.test.ts @@ -31,17 +31,40 @@ 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"); 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 +116,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", }, ); @@ -125,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( { - slackUserId: "U123", - slackUserName: "alice", - slackUserEmail: "Alice@Example.COM", - modelId: "openai/gpt-5.4", + conversationId: "thread_123", + platform: "slack", + userId: "U123", + userName: "alice", + userEmail: "Alice@Example.COM", + }, + async () => { + setTags({ assistantUserName: "junior" }); + return logException( + new Error("boom"), + "turn_failed", + { modelId: "openai/gpt-5.4" }, + {}, + "Turn failed", + ); }, - {}, - "Turn failed", ); expect(eventId).toBe("event-id"); @@ -153,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)); }); }); 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");