From 5c77202949d1f391bb886aa68359bbfb438d220f Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 11 Feb 2026 17:48:57 +0100 Subject: [PATCH] progresS --- .../suites/tracing/cloudflare-agents/index.ts | 88 +++++ .../suites/tracing/cloudflare-agents/test.ts | 169 +++++++++ .../tracing/cloudflare-agents/wrangler.jsonc | 6 + packages/cloudflare/src/index.ts | 2 + .../tracing/cloudflare-agents/constants.ts | 55 +++ .../tracing/cloudflare-agents/index.ts | 323 ++++++++++++++++++ .../tracing/cloudflare-agents/types.ts | 36 ++ .../tracing/cloudflare-agents/utils.ts | 49 +++ packages/core/src/semanticAttributes.ts | 37 ++ packages/core/src/tracing/index.ts | 1 + 10 files changed, 766 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/wrangler.jsonc create mode 100644 packages/cloudflare/src/integrations/tracing/cloudflare-agents/constants.ts create mode 100644 packages/cloudflare/src/integrations/tracing/cloudflare-agents/index.ts create mode 100644 packages/cloudflare/src/integrations/tracing/cloudflare-agents/types.ts create mode 100644 packages/cloudflare/src/integrations/tracing/cloudflare-agents/utils.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/index.ts new file mode 100644 index 000000000000..12f3473da4f3 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/index.ts @@ -0,0 +1,88 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// Mock Agent class for testing +class MockAgent { + public state: { count: number }; + + public constructor() { + this.state = { count: 0 }; + } + + public setState(newState: { count: number }): void { + this.state = newState; + } + + // Simulate a callable method + public async increment(value: number): Promise { + const newCount = this.state.count + value; + this.setState({ count: newCount }); + return newCount; + } + + // Simulate a callable method with complex input/output + public async processTask(task: { id: string; action: string }): Promise<{ result: string; timestamp: number }> { + return { + result: `Processed task ${task.id} with action ${task.action}`, + timestamp: Date.now(), + }; + } + + // Simulate a callable method that returns Response + public async handleRequest(message: string): Promise { + return new Response(JSON.stringify({ message: `Received: ${message}` }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +// Instrument the agent with different options based on query params +function getInstrumentedAgent(recordInputs: boolean, recordOutputs: boolean, recordStateChanges: boolean): MockAgent { + const InstrumentedAgent = Sentry.instrumentCloudflareAgent(MockAgent as any, { + recordInputs, + recordOutputs, + recordStateChanges, + }); + return new InstrumentedAgent(); +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request: Request, _env, _ctx) { + const url = new URL(request.url); + const path = url.pathname; + + // Parse options from query params + const recordInputs = url.searchParams.get('recordInputs') === 'true'; + const recordOutputs = url.searchParams.get('recordOutputs') === 'true'; + const recordStateChanges = url.searchParams.get('recordStateChanges') === 'true'; + + const agent = getInstrumentedAgent(recordInputs, recordOutputs, recordStateChanges); + + if (path === '/increment') { + const result = await agent.increment(5); + return new Response(JSON.stringify({ count: result })); + } + + if (path === '/process-task') { + const result = await agent.processTask({ id: 'task-1', action: 'analyze' }); + return new Response(JSON.stringify(result)); + } + + if (path === '/handle-request') { + const response = await agent.handleRequest('Hello, Agent!'); + return response; + } + + return new Response('Not found', { status: 404 }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/test.ts new file mode 100644 index 000000000000..a141c2163f77 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/test.ts @@ -0,0 +1,169 @@ +import type { TransactionEvent } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, +} from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { createRunner } from '../../../runner'; + +it('traces a basic callable method invocation', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + + expect(transactionEvent.transaction).toBe('GET /increment'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.cloudflare.agents', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'cloudflare.agents', + 'cloudflare.agents.method': 'increment', + 'cloudflare.agents.agent': 'MockAgent', + }), + description: 'MockAgent.increment', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.cloudflare.agents', + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.completed(); +}); + +it('traces callable method with inputs when recordInputs=true', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + + expect(transactionEvent.transaction).toBe('GET /process-task'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.cloudflare.agents', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'cloudflare.agents', + 'cloudflare.agents.method': 'processTask', + 'cloudflare.agents.agent': 'MockAgent', + 'cloudflare.agents.input': expect.stringContaining('task-1'), + }), + description: 'MockAgent.processTask', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.cloudflare.agents', + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/process-task?recordInputs=true'); + await runner.completed(); +}); + +it('traces callable method with outputs when recordOutputs=true', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + + expect(transactionEvent.transaction).toBe('GET /process-task'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.cloudflare.agents', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'cloudflare.agents', + 'cloudflare.agents.method': 'processTask', + 'cloudflare.agents.agent': 'MockAgent', + 'cloudflare.agents.output': expect.stringContaining('Processed task'), + }), + description: 'MockAgent.processTask', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.cloudflare.agents', + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/process-task?recordOutputs=true'); + await runner.completed(); +}); + +it('traces callable method with state changes when recordStateChanges=true', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + + expect(transactionEvent.transaction).toBe('GET /increment'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.cloudflare.agents', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'cloudflare.agents', + 'cloudflare.agents.method': 'increment', + 'cloudflare.agents.agent': 'MockAgent', + 'cloudflare.agents.state.before': expect.stringContaining('count'), + 'cloudflare.agents.state.after': expect.stringContaining('count'), + }), + description: 'MockAgent.increment', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.cloudflare.agents', + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/increment?recordStateChanges=true'); + await runner.completed(); +}); + +it('handles Response outputs correctly', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + + expect(transactionEvent.transaction).toBe('GET /handle-request'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.cloudflare.agents', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'cloudflare.agents', + 'cloudflare.agents.method': 'handleRequest', + 'cloudflare.agents.agent': 'MockAgent', + 'cloudflare.agents.output.type': 'Response', + 'cloudflare.agents.output.status': 200, + }), + description: 'MockAgent.handleRequest', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.cloudflare.agents', + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/handle-request?recordOutputs=true'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/wrangler.jsonc new file mode 100644 index 000000000000..24fb2861023d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-agents/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"] +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 33572c81714d..e66c4e06f6dc 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -118,6 +118,8 @@ export { getDefaultIntegrations } from './sdk'; export { fetchIntegration } from './integrations/fetch'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; +export { instrumentCloudflareAgent } from './integrations/tracing/cloudflare-agents/index'; +export type { InstrumentCloudflareAgentsOptions } from './integrations/tracing/cloudflare-agents/index'; export { honoIntegration } from './integrations/hono'; export { instrumentD1WithSentry } from './d1'; diff --git a/packages/cloudflare/src/integrations/tracing/cloudflare-agents/constants.ts b/packages/cloudflare/src/integrations/tracing/cloudflare-agents/constants.ts new file mode 100644 index 000000000000..35574b6efedc --- /dev/null +++ b/packages/cloudflare/src/integrations/tracing/cloudflare-agents/constants.ts @@ -0,0 +1,55 @@ +/** + * Constants for Cloudflare Agents integration + */ + +// ============================================================================= +// OPENTELEMETRY SEMANTIC CONVENTIONS +// Re-exported from @sentry/core for consistency across SDKs +// ============================================================================= + +export { + GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_OPERATION_TYPE_ATTRIBUTE, + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, +} from '@sentry/core'; + +// ============================================================================= +// CLOUDFLARE AGENTS SPECIFIC +// ============================================================================= + +/** + * The system identifier for Cloudflare Agents + * Used as the value for gen_ai.system attribute + */ +export const CLOUDFLARE_AGENTS_SYSTEM = 'cloudflare_agents'; + +/** + * The Sentry origin for Cloudflare Agents spans + */ +export const CLOUDFLARE_AGENTS_ORIGIN = 'auto.ai.cloudflare.agents'; + +/** + * Default operation name for invoke_agent spans + */ +export const DEFAULT_INVOKE_AGENT_OPERATION = 'invoke_agent'; + +/** + * Internal lifecycle methods that should NOT be instrumented. + * These are framework methods that don't represent entry points. + */ +export const INTERNAL_METHODS = ['setState', 'broadcast', 'onClose', 'onError', 'onStateUpdate'] as const; + +/** + * Known entry point methods that SHOULD be instrumented. + * These represent external calls into the agent. + */ +export const LIFECYCLE_ENTRY_POINTS = [ + 'onRequest', // HTTP handler + 'onConnect', // WebSocket connection handler + 'onMessage', // WebSocket message handler +] as const; + +export type LifecycleEntryPoint = (typeof LIFECYCLE_ENTRY_POINTS)[number]; diff --git a/packages/cloudflare/src/integrations/tracing/cloudflare-agents/index.ts b/packages/cloudflare/src/integrations/tracing/cloudflare-agents/index.ts new file mode 100644 index 000000000000..5545fb581f4d --- /dev/null +++ b/packages/cloudflare/src/integrations/tracing/cloudflare-agents/index.ts @@ -0,0 +1,323 @@ +/** + * Cloudflare Agents Integration + * + * Provides tracing for Cloudflare Agents by wrapping entry point methods. + */ + +import type { Span } from '@sentry/core'; +import { + _INTERNAL_getTruncatedJsonString, + getActiveSpan, + getClient, + isThenable, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startSpan, +} from '@sentry/core'; +import { + CLOUDFLARE_AGENTS_ORIGIN, + CLOUDFLARE_AGENTS_SYSTEM, + DEFAULT_INVOKE_AGENT_OPERATION, + GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_OPERATION_TYPE_ATTRIBUTE, + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, +} from './constants'; +import type { InstrumentCloudflareAgentsOptions } from './types'; +import { shouldInstrumentMethod } from './utils'; + +// Track which agent classes we've already instrumented +const _instrumentedClasses = new WeakSet(); + +/** + * Instruments a Cloudflare Agent class by wrapping its entry point methods. + * + * This creates `gen_ai.invoke_agent` spans at agent entry points (RPC, HTTP, WebSocket) + * but NOT for internal method calls. This ensures proper span hierarchy and avoids duplicate spans. + * + * **IMPORTANT**: You must pass YOUR specific agent class (e.g., `CounterAgent`), not the base + * `Agent` class from the 'agents' package. + * + * **Recording inputs and outputs:** + * + * By default, inputs and outputs are controlled by the `sendDefaultPii` option. To explicitly + * enable or disable recording: + * + * ```typescript + * instrumentCloudflareAgent(CounterAgent, { + * recordInputs: true, // Records method arguments using gen_ai.input.messages + * recordOutputs: true, // Records method return values using gen_ai.output.messages + * }) + * ``` + * + * **What gets instrumented (entry points only):** + * -`@callable()` methods - Records arguments as gen_ai.input.messages + * -`onRequest(request)` - Records HTTP request info + * -`onMessage(connection, message)` - Records WebSocket message content + * -`onConnect(connection, ctx)` - Records connection info + * **Additional notes:** + * - Inputs/outputs are formatted as message objects following Sentry semantic conventions + * - The underlying AI model calls (e.g., OpenAI) should be instrumented separately + * - Call this once at module initialization, before handling any requests + * + * @param AgentClass - YOUR agent class (e.g., CounterAgent), NOT the base Agent from 'agents' + * @param options - Options for instrumentation + * + * @example + * ```typescript + * import * as Sentry from '@sentry/cloudflare'; + * import { Agent } from 'agents'; + * import OpenAI from 'openai'; + * + * // Define your agent class + * export class CounterAgent extends Agent { + * async onRequest(request: Request): Promise { + * const openai = new OpenAI({ apiKey: this.env.OPENAI_API_KEY }); + * const completion = await openai.chat.completions.create({ + * model: 'gpt-4', + * messages: [{ role: 'user', content: 'Hello' }] + * }); + * return Response.json({ text: completion.choices[0].message.content }); + * } + * + * @callable() + * async increment() { + * this.setState({ count: this.state.count + 1 }); + * return this.state.count; + * } + * } + * + * + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * tracesSampleRate: 1.0, + * }), + * { + * async fetch(request, env, ctx) { + * // Instrument your agent class + * Sentry.instrumentCloudflareAgent(CounterAgent, { + * recordInputs: true, + * recordOutputs: true, + * }); + * + * const agentId = env.COUNTER_AGENT.idFromName('my-counter'); + * const agent = env.COUNTER_AGENT.get(agentId); + * return await agent.fetch(request); + * }, + * } + * ); + * ``` + */ +export function instrumentCloudflareAgent( + AgentClass: T & { prototype: object; name: string }, + options: InstrumentCloudflareAgentsOptions = {}, +): T { + // Skip if already instrumented + if (_instrumentedClasses.has(AgentClass)) { + return AgentClass; + } + + const client = getClient(); + if (!client) { + return AgentClass; + } + + const shouldRecordInputs = options.recordInputs ?? client.getOptions().sendDefaultPii ?? false; + const shouldRecordOutputs = options.recordOutputs ?? client.getOptions().sendDefaultPii ?? false; + + instrumentAgentClass(AgentClass, shouldRecordInputs, shouldRecordOutputs); + _instrumentedClasses.add(AgentClass); + + return AgentClass; +} + +/** + * Instruments a specific agent class by wrapping its entry point methods + */ +function instrumentAgentClass( + AgentClass: { prototype: object; name: string }, + shouldRecordInputs: boolean, + shouldRecordOutputs: boolean, +): void { + // Get all property names from the prototype + const proto = AgentClass.prototype as Record; + const propertyNames = Object.getOwnPropertyNames(proto); + + // Get the base Agent class properties to filter them out + const baseProto = Object.getPrototypeOf(proto); + const basePropertyNames = baseProto ? Object.getOwnPropertyNames(baseProto) : []; + + for (const methodName of propertyNames) { + const descriptor = Object.getOwnPropertyDescriptor(proto, methodName); + if (!descriptor || typeof descriptor.value !== 'function') { + continue; + } + + // Determine if this is a user-defined method (not inherited from Agent base) + const isUserDefined = !basePropertyNames.includes(methodName); + + if (!shouldInstrumentMethod(methodName, isUserDefined)) { + continue; + } + + const originalMethod = descriptor.value as (...args: unknown[]) => unknown; + + // Replace with instrumented version + proto[methodName] = function (this: object, ...args: unknown[]): unknown { + // Check if we're already inside an invoke_agent span (to detect internal calls) + const activeSpan = getActiveSpan(); + const isAlreadyInInvokeAgent = + activeSpan && spanToJSON(activeSpan).op === `gen_ai.${DEFAULT_INVOKE_AGENT_OPERATION}`; + + if (isAlreadyInInvokeAgent) { + return originalMethod.apply(this, args); + } + + const agentName = this.constructor.name; + const spanName = `${agentName}.${methodName}`; + + return startSpan( + { + name: spanName, + op: `gen_ai.${DEFAULT_INVOKE_AGENT_OPERATION}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: CLOUDFLARE_AGENTS_ORIGIN, + [GEN_AI_SYSTEM_ATTRIBUTE]: CLOUDFLARE_AGENTS_SYSTEM, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: DEFAULT_INVOKE_AGENT_OPERATION, + [GEN_AI_OPERATION_TYPE_ATTRIBUTE]: 'agent', + [GEN_AI_AGENT_NAME_ATTRIBUTE]: agentName, + }, + }, + (span: Span) => { + // Record inputs based on entry point type + if (shouldRecordInputs && args.length > 0) { + try { + if (methodName === 'onMessage') { + // onMessage(connection, message) + // args[0] = connection, args[1] = message (string or ArrayBuffer) + const message = args[1]; + recordMessageInput(span, message); + } else if (methodName === 'onRequest') { + // onRequest(request: Request) + const request = args[0] as Request; + recordRequestInput(span, request); + } else { + // For @callable methods and onConnect, record arguments as gen_ai.input.messages + span.setAttribute( + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + JSON.stringify([ + { role: 'user', parts: [{ type: 'text', content: _INTERNAL_getTruncatedJsonString(args) }] }, + ]), + ); + } + } catch { + // Ignore serialization errors + } + } + + const result = originalMethod.apply(this, args); + + // Handle async results + if (isThenable(result)) { + return Promise.resolve(result).then((resolved: unknown) => { + if (shouldRecordOutputs) { + recordOutputMessages(span, resolved); + } + return resolved; + }); + } + + // Handle sync results + if (shouldRecordOutputs) { + recordOutputMessages(span, result); + } + return result; + }, + ); + }; + } +} + +/** + * Records WebSocket message input + */ +function recordMessageInput(span: Span, message: unknown): void { + try { + let messageContent: string; + + if (typeof message === 'string') { + messageContent = message; + } else if (message instanceof ArrayBuffer) { + messageContent = ``; + } else { + messageContent = _INTERNAL_getTruncatedJsonString(message); + } + + span.setAttribute( + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: messageContent }] }]), + ); + } catch { + // Ignore serialization errors + } +} + +/** + * Records HTTP request input + * Note: HTTP attributes (method, url, etc.) are already set by the parent HTTP span, + * so we only record the gen_ai.input.messages attribute here. + */ +function recordRequestInput(span: Span, request: Request): void { + try { + const url = new URL(request.url); + + // Record as gen_ai input + const requestInfo = { + method: request.method, + path: url.pathname, + query: url.search, + }; + + span.setAttribute( + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: JSON.stringify(requestInfo) }] }]), + ); + } catch { + // Ignore serialization errors + } +} + +/** + * Records the output using the standard gen_ai.output.messages attribute + */ +function recordOutputMessages(span: Span, result: unknown): void { + try { + // Skip Response objects - they don't serialize well and the actual AI output + // is captured by child spans (e.g., OpenAI integration) + // HTTP response status is already set by the parent HTTP span + if (result && typeof result === 'object' && result instanceof Response) { + return; + } + + // Format as messages according to semantic conventions + const outputMessages = [ + { + role: 'assistant', + parts: [ + { + type: 'text', + content: _INTERNAL_getTruncatedJsonString(result), + }, + ], + }, + ]; + span.setAttribute(GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, JSON.stringify(outputMessages)); + } catch { + // Ignore serialization errors + } +} + +export type { InstrumentCloudflareAgentsOptions } from './types'; diff --git a/packages/cloudflare/src/integrations/tracing/cloudflare-agents/types.ts b/packages/cloudflare/src/integrations/tracing/cloudflare-agents/types.ts new file mode 100644 index 000000000000..63c89a7458f3 --- /dev/null +++ b/packages/cloudflare/src/integrations/tracing/cloudflare-agents/types.ts @@ -0,0 +1,36 @@ +/** + * Types for Cloudflare Agents integration + */ + +/** + * Options for instrumenting Cloudflare Agents + */ +export interface InstrumentCloudflareAgentsOptions { + /** + * Whether to record inputs (method arguments) to callable methods. + * Uses the standard `gen_ai.input.messages` attribute. + * Defaults to the value of `sendDefaultPii` from the client options. + * + * @default undefined (uses sendDefaultPii) + */ + recordInputs?: boolean; + + /** + * Whether to record outputs (method return values) from callable methods. + * Uses the standard `gen_ai.output.messages` attribute. + * Defaults to the value of `sendDefaultPii` from the client options. + * + * @default undefined (uses sendDefaultPii) + */ + recordOutputs?: boolean; + + /** + * Whether to record state changes from callable methods. + * Records the agent's state before and after method execution. + * Defaults to the value of `sendDefaultPii` from the client options. + * + * @default undefined (uses sendDefaultPii) + */ + recordStateChanges?: boolean; +} + diff --git a/packages/cloudflare/src/integrations/tracing/cloudflare-agents/utils.ts b/packages/cloudflare/src/integrations/tracing/cloudflare-agents/utils.ts new file mode 100644 index 000000000000..10cb735342d1 --- /dev/null +++ b/packages/cloudflare/src/integrations/tracing/cloudflare-agents/utils.ts @@ -0,0 +1,49 @@ +/** + * Utility functions for Cloudflare Agents integration + */ + +import { INTERNAL_METHODS, LIFECYCLE_ENTRY_POINTS, type LifecycleEntryPoint } from './constants'; + +/** + * Checks if a method is a known lifecycle entry point + */ +export function isLifecycleEntryPoint(propertyName: string): propertyName is LifecycleEntryPoint { + return LIFECYCLE_ENTRY_POINTS.includes(propertyName as LifecycleEntryPoint); +} + +/** + * Checks if a method should be instrumented as an entry point. + * + * @param propertyName - The method name to check + * @param isUserDefined - Whether the method is defined on the user's class (not inherited from Agent base) + * + * Returns true for: + * - Known lifecycle entry points (onRequest, onMessage, onConnect) + * - User-defined methods that are likely @callable() (public, not in INTERNAL_METHODS) + * + * Returns false for: + * - Constructor + * - Private methods (starting with _) + * - Internal Agent methods (setState, broadcast, etc.) + */ +export function shouldInstrumentMethod(propertyName: string, isUserDefined: boolean): boolean { + // Skip constructor and private methods + if (propertyName === 'constructor' || propertyName.startsWith('_')) { + return false; + } + + // Skip Agent internal methods + if (INTERNAL_METHODS.includes(propertyName as (typeof INTERNAL_METHODS)[number])) { + return false; + } + + // Always instrument known lifecycle entry points + if (isLifecycleEntryPoint(propertyName)) { + return true; + } + + // For user-defined methods: only instrument if they're public and not internal + // These are likely @callable() methods + // For base class methods: skip (already checked lifecycle entry points above) + return isUserDefined; +} diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 88b0f470dfa3..b08387a3e65f 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -83,9 +83,46 @@ export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type'; * GEN AI ATTRIBUTES * Based on OpenTelemetry Semantic Conventions for Generative AI * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/ + * @see https://getsentry.github.io/sentry-conventions/attributes/gen_ai/ * ============================================================================= */ +/** + * The Generative AI system being used. + * Examples: 'openai', 'anthropic', 'google_genai', 'cloudflare_agents' + */ +export const GEN_AI_SYSTEM_ATTRIBUTE = 'gen_ai.system'; + +/** + * The name of the operation being performed. + * Well-known values: 'chat', 'create_agent', 'embeddings', 'execute_tool', + * 'generate_content', 'invoke_agent', 'text_completion' + */ +export const GEN_AI_OPERATION_NAME_ATTRIBUTE = 'gen_ai.operation.name'; + +/** + * The type of AI operation. + * Must be one of: 'agent', 'ai_client', 'tool', 'handoff', 'guardrail' + */ +export const GEN_AI_OPERATION_TYPE_ATTRIBUTE = 'gen_ai.operation.type'; + +/** + * The name of the agent being used. + */ +export const GEN_AI_AGENT_NAME_ATTRIBUTE = 'gen_ai.agent.name'; + +/** + * The messages passed to the model. + * Must be a stringified array of message objects with 'role' attribute. + */ +export const GEN_AI_INPUT_MESSAGES_ATTRIBUTE = 'gen_ai.input.messages'; + +/** + * The model's response messages. + * Must be a stringified array of message objects. + */ +export const GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE = 'gen_ai.output.messages'; + /** * The conversation ID for linking messages across API calls. * For OpenAI Assistants API: thread_id diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 9997cab3519b..d47e302cbabf 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -23,3 +23,4 @@ export { export { setMeasurement, timedEventsToMeasurements } from './measurement'; export { sampleSpan } from './sampling'; export { logSpanEnd, logSpanStart } from './logSpans'; +export { getTruncatedJsonString as _INTERNAL_getTruncatedJsonString } from './ai/utils';