Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use the query recipes below to find the failing turn and next query.
| `messaging.destination.name` | Slack channel | logs, spans | channel-scoped search |
| `gen_ai.tool.name` | tool name | tool spans/logs | tool failures |
| `app.credential.provider` | auth provider | auth logs | auth/resume search |
| `user.id` | accountable person | logs, spans, errors | person's actions |
| `app.task.id` | scheduled/event automation id | task lifecycle logs | task timeline |
| `app.task.run.id` | scheduled-automation run id | scheduler run logs | run outcome |
| `app.dispatch.id` | agent dispatch id | task/dispatch logs | fire conversation |
Expand Down Expand Up @@ -127,6 +128,16 @@ fields=timestamp,event.name,app.task.id,app.task.run.id,app.dispatch.id,app.task
sort=-timestamp
```

Credentialed provider writes, with whose authority they used. Write-grant
egress requests are audit events (`app.log.audit:true`), so Sentry keeps them
in production. Other info events stay out of Sentry in production.

```text
dataset=logs query='app.log.audit:true event.name:sandbox.egress.upstream.requested'
fields=timestamp,user.id,app.actor.type,app.actor.id,app.credential.allowed_when,app.task.id,gen_ai.conversation.id,app.provider.name,app.grant.name,http.request.method,server.address,url.path,http.response.status_code
sort=-timestamp
```

Auth, credential, and resume failures.

```text
Expand All @@ -135,6 +146,23 @@ fields=timestamp,event.name,gen_ai.conversation.id,app.credential.provider,app.a
sort=-timestamp
```

## Identity

Runs, resumes, and the sandbox egress proxy bind the same identity attributes.
Nested logs and spans inherit them.

- `user.id`, `user.name`, `user.email`, `user.full_name`: the accountable
person. For a user Actor, this is that user. For a system Actor, this is the
owner of delegated credentials, such as the Automation creator. It is empty
when a system run has no delegated credentials. Sentry also uses `user.id`
for its user features.
- `app.actor.type`, `app.actor.id`: who acted. The type is `user` or `system`.
- `app.credential.allowed_when`, `app.task.id`: why delegated credentials were
allowed, and for which Automation.

Do not add app-specific keys under `user.*`. Put facts about the request in
`app.*`.

## Domains

### Database Queries
Expand Down Expand Up @@ -259,6 +287,12 @@ Attributes: `gen_ai.tool.name`, `gen_ai.tool.call.id`,
`app.sandbox.search.result_bytes`, `app.sandbox.search.limit`,
`app.sandbox.search.limit_reached`, `app.tool.missing_annotations`

Sandbox egress events: `sandbox.egress.upstream.requested` is logged for every
forwarded request outside production. In production, it is logged only for
write grants, as an audit event. Attributes: `app.provider.name`,
`app.grant.name`, `app.grant.access`, `app.log.audit`, and the identity
attributes below. The proxy binds them from the signed credential token.

### Scheduled automations

A scheduled automation was created, claimed, dispatched, completed, or failed
Expand Down
21 changes: 5 additions & 16 deletions packages/junior/src/chat/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ import {
type ConversationMessageProvenance,
} from "@/chat/conversations/provenance";
import type { Actor } from "@/chat/actor";
import { credentialUserSubjectId } from "@/chat/credentials/context";
import {
actorLogContext,
credentialUserSubjectId,
} from "@/chat/credentials/context";
import {
GEN_AI_PROVIDER_NAME,
completeObject,
Expand Down Expand Up @@ -240,9 +243,7 @@ export async function executeAgentRun(
const conversationPrivacy = resolveConversationPrivacy({
visibility,
});
const credentialActor = run.credentialContext?.actor;
const actor = actorFromRun(run);
const userActor = actor && "userId" in actor ? actor : undefined;
const runLogContext: LogContext = {
conversationId: run.conversationId,
platform:
Expand All @@ -264,20 +265,8 @@ export async function executeAgentRun(
: run.source.kind === "web" || run.source.kind === "local"
? run.source.conversationId
: run.conversationId),
userId: userActor?.userId,
userName: userActor?.userName,
userEmail: userActor?.email,
...actorLogContext(actor, run.credentialContext),
runId: run.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, () =>
Expand Down
2 changes: 2 additions & 0 deletions packages/junior/src/chat/agent/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface AgentSandboxOptions {
traceContext: LogContext;
tracePropagation?: SandboxEgressTracePropagationConfig;
egressSignals?: SandboxEgressSignalTransport;
conversationId?: string;
credentialEgress?: CredentialContext;
actor?: Actor;
locationConfiguration?: LocationConfigurationService;
Expand Down Expand Up @@ -166,6 +167,7 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox {
traceContext: options.traceContext,
tracePropagation: options.tracePropagation,
egressSignals: options.egressSignals,
conversationId: options.conversationId,
credentialEgress: options.credentialEgress,
prepare: options.prepareSandbox,
prepareWorkspace: options.prepareWorkspace,
Expand Down
1 change: 1 addition & 0 deletions packages/junior/src/chat/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export async function wireAgentTools(
traceContext: args.spanContext,
tracePropagation: args.run.environment?.sandboxTracePropagation,
egressSignals: args.run.environment?.sandboxEgressSignals,
conversationId: args.run.conversationId,
credentialEgress: args.run.credentialContext,
actor: args.currentActor,
locationConfiguration: args.run.environment?.locationConfiguration,
Expand Down
56 changes: 55 additions & 1 deletion packages/junior/src/chat/credentials/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod";
import { parseActorUserId, type Actor } from "@/chat/actor";
import { isUserActor, parseActorUserId, type Actor } from "@/chat/actor";
import type { LogContext } from "@/chat/log-context";

const exactActorIdSchema = z
.string()
Expand Down Expand Up @@ -159,6 +160,59 @@ export function credentialContextForActor(
return { actor: { type: "user", userId: actor.userId } };
}

/**
* Name the person accountable for credential authority in logs.
*
* `userId` is the user actor, or the owner of delegated credentials when the
* system acts for an Automation or Watch. `actorType` says who acted.
*/
export function credentialLogContext(context: CredentialContext): LogContext {
if ("type" in context.actor) {
return {
userId: context.actor.userId,
actorType: "user",
actorId: context.actor.userId,
};
}
const subject = "subject" in context ? context.subject : undefined;
return {
actorType: "system",
actorId: context.actor.name,
...(subject
? {
userId: subject.userId,
credentialAllowedWhen: subject.allowedWhen,
...("taskId" in subject ? { taskId: subject.taskId } : undefined),
}
: undefined),
};
}

/**
* Name the run's people in logs: the accountable person and who acted.
*
* A user actor is the accountable person. A system actor names the owner of
* delegated credentials, when the run has them.
*/
export function actorLogContext(
actor: Actor | undefined,
credentialContext: CredentialContext | undefined,
): LogContext {
const credential = credentialContext
? credentialLogContext(credentialContext)
: {};
if (!isUserActor(actor)) {
return credential;
}
return {
...credential,
userId: actor.userId,
userName: actor.userName,
userEmail: actor.email,
userFullName: actor.fullName,
};
}

/** Return the user whose OAuth token may satisfy this credential request. */
export function credentialUserSubjectId(
context: CredentialContext,
Expand Down
39 changes: 28 additions & 11 deletions packages/junior/src/chat/egress/credentialed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { logException, logInfo, logWarn } from "@/chat/logging";
import { logAudit, logException, logInfo, logWarn } from "@/chat/logging";
import { onPluginEgressResponse } from "@/chat/plugins/credential-hooks";
import { matchesSandboxEgressDomain } from "@/chat/sandbox/egress/policy";
import {
Expand Down Expand Up @@ -170,15 +170,23 @@ function egressAttributes(input: {
status?: number;
}): Record<string, unknown> {
return {
...(input.egressId ? { "app.sandbox.egress_id": input.egressId } : undefined),
...(input.egressId
? { "app.sandbox.egress_id": input.egressId }
: undefined),
...(input.provider ? { "app.provider.name": input.provider } : undefined),
...(input.grantName ? { "app.grant.name": input.grantName } : undefined),
...(input.grantAccess ? { "app.grant.access": input.grantAccess } : undefined),
...(input.grantReason ? { "app.grant.reason": input.grantReason } : undefined),
...(input.grantAccess
? { "app.grant.access": input.grantAccess }
: undefined),
...(input.grantReason
? { "app.grant.reason": input.grantReason }
: undefined),
...(input.host ? { "server.address": input.host } : undefined),
...(input.method ? { "http.request.method": input.method } : undefined),
...(input.path ? { "url.path": input.path } : undefined),
...(input.status ? { "http.response.status_code": input.status } : undefined),
...(input.status
? { "http.response.status_code": input.status }
: undefined),
};
}

Expand Down Expand Up @@ -270,6 +278,11 @@ function isEgressPolicyDenied(error: unknown): error is EgressPolicyDenied {
);
}

/**
* Record one forwarded provider request. Write-grant requests are audit
* events, so they reach Sentry in production. Other requests log only outside
* production.
*/
function logSandboxEgressUpstreamRequest(input: {
egressId: string;
grantAccess?: "read" | "write";
Expand All @@ -280,11 +293,12 @@ function logSandboxEgressUpstreamRequest(input: {
upstream: Response;
upstreamUrl: URL;
}): void {
if (!shouldLogSandboxEgressInfo()) {
const isWrite = input.grantAccess === "write";
if (!isWrite && !shouldLogSandboxEgressInfo()) {
return;
}

logInfo("sandbox.egress.upstream.requested", {
(isWrite ? logAudit : logInfo)("sandbox.egress.upstream.requested", {
...egressAttributes({
egressId: input.egressId,
grantAccess: input.grantAccess,
Expand Down Expand Up @@ -563,7 +577,9 @@ async function recordSandboxAuthRequired(input: {
provider: input.provider,
grant: input.grant,
kind: input.kind ?? "auth_required",
...(input.authorization ? { authorization: input.authorization } : undefined),
...(input.authorization
? { authorization: input.authorization }
: undefined),
message: input.message,
});
}
Expand Down Expand Up @@ -687,7 +703,9 @@ export async function executeCredentialedEgressRequest(input: {
const recordPermissionDenied =
deps.recordPermissionDenied ?? recordSandboxPermissionDenied;

async function resolveLease(): Promise<SandboxEgressCredentialLease | Response> {
async function resolveLease(): Promise<
SandboxEgressCredentialLease | Response
> {
try {
return await issueCredentialLease(
provider,
Expand Down Expand Up @@ -792,8 +810,7 @@ export async function executeCredentialedEgressRequest(input: {
return intercepted;
}

const requestBody =
body instanceof ArrayBuffer ? body.slice(0) : body;
const requestBody = body instanceof ArrayBuffer ? body.slice(0) : body;
const upstream = await fetchImpl(upstreamUrl, {
method: request.method,
headers,
Expand Down
15 changes: 13 additions & 2 deletions packages/junior/src/chat/log-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { normalizeIdentityEmail } from "@/chat/identities/identity";

export type LogAttributeValue = string | number | boolean | string[];
export type LogAttributes = Record<string, LogAttributeValue>;
Expand All @@ -10,12 +11,18 @@ export interface LogContext {
requestId?: string;
messageConversationId?: string;
destinationName?: string;
/** The accountable person: the user Actor, or the delegated credential owner. */
userId?: string;
userName?: string;
userEmail?: string;
userFullName?: string;
runId?: string;
/** Who acted: `user` or `system`. */
actorType?: string;
actorId?: string;
/** Why delegated credentials were allowed, and for which Automation. */
taskId?: string;
credentialAllowedWhen?: string;
assistantUserName?: string;
modelId?: string;
skillName?: string;
Expand Down Expand Up @@ -57,11 +64,15 @@ export function logContextToAttributes(context: LogContext): LogAttributes {
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,
"user.id": context.userId,
"user.name": context.userName,
"user.email": normalizeIdentityEmail(context.userEmail),
"user.full_name": context.userFullName,
"app.run.id": context.runId,
"app.actor.type": context.actorType,
"app.actor.id": context.actorId,
"app.task.id": context.taskId,
"app.credential.allowed_when": context.credentialAllowedWhen,
"gen_ai.agent.name": context.assistantUserName,
"gen_ai.request.model": context.modelId,
"app.skill.name": context.skillName,
Expand Down
36 changes: 31 additions & 5 deletions packages/junior/src/chat/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,10 @@ const CONSOLE_PRIORITY_INDEX: Map<string, number> = new Map(
const CONSOLE_ALWAYS_HIDDEN_KEYS = new Set([
"gen_ai.agent.name",
"app.platform",
"enduser.id",
"enduser.pseudo.id",
"user.id",
"user.name",
"user.email",
"user.full_name",
"http.request.method",
"messaging.system",
"url.full",
Expand Down Expand Up @@ -217,8 +219,18 @@ function getSentryEnvironment(): string {
.toLowerCase();
}

function shouldSuppressInfoLog(level: LogLevel): boolean {
return getSentryEnvironment() === "production" && level === "info";
/** Marks an audit event. Sentry keeps it in production. */
const AUDIT_LOG_ATTRIBUTE = "app.log.audit";

function shouldSuppressInfoLog(
level: LogLevel,
attributes: LogAttributes,
): boolean {
return (
getSentryEnvironment() === "production" &&
level === "info" &&
attributes[AUDIT_LOG_ATTRIBUTE] !== true
);
}

function shouldEmitConsole(level: LogLevel): boolean {
Expand Down Expand Up @@ -643,7 +655,7 @@ function emitSentry(
body: string,
attributes: LogAttributes,
): void {
if (shouldSuppressInfoLog(level)) {
if (shouldSuppressInfoLog(level, attributes)) {
return;
}

Expand Down Expand Up @@ -1541,6 +1553,20 @@ export function logInfo(
log.info(eventName, attributes);
}

/**
* Emit an audit event: an info event that Sentry also keeps in production.
*
* Use it only to record who caused an external side effect and why. Other
* info events stay out of Sentry in production. Sentry log retention is not
* durable audit storage.
*/
export function logAudit(
eventName: string,
attributes: Record<string, unknown> = {},
): void {
log.info(eventName, { ...attributes, [AUDIT_LOG_ATTRIBUTE]: true });
}

/**
* Emit a warning event with occurrence-specific attributes.
*
Expand Down
Loading
Loading