Skip to content
Open
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
3 changes: 3 additions & 0 deletions .cursor/BUGBOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu
- When calling any `startSpan` API (`startInactiveSpan`, `startSpanManual`, etc), always ensure that the following span attributes are set:
- `SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN` (`'sentry.origin'`) with a proper span origin
- a proper origin must only contain [a-z], [A-Z], [0-9], `_` and `.` characters.
- a proper origin has at most four parts, `<type>.<category>.<integration_name>.<integration_part>`, and must not skip a part. `type` is `auto` or `manual`.
- `category` must be a span op category (e.g. `http`, `db`, `queue`, `function`, `ai`, `ui`, `rpc`), matching the span's op. Flag origins that put a library or vendor in the category slot (e.g. `auto.kafkajs.producer` instead of `auto.queue.kafkajs.producer`).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `category` must be a span op category (e.g. `http`, `db`, `queue`, `function`, `ai`, `ui`, `rpc`), matching the span's op. Flag origins that put a library or vendor in the category slot (e.g. `auto.kafkajs.producer` instead of `auto.queue.kafkajs.producer`).
- `category` must be a span op category (e.g. `http`, `db`, `queue`, `function`, `gen_ai`, `ui`, `rpc`), matching the span's op. Flag origins that put a library or vendor in the category slot (e.g. `auto.kafkajs.producer` instead of `auto.queue.kafkajs.producer`).

- the origin identifies the integration, not the tooling behind it. Flag leftover implementation markers like `otel` or `orchestrion` used as a part.
- flag any non-conforming origin values as invalid and link to the trace origin specification (https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/)
- `SEMANTIC_ATTRIBUTE_SENTRY_OP` (`'sentry.op'`) with a proper span op
- Span ops should be lower case only, and use snake_case. The `.` character is used to delimit op parts.
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ Uses **Git Flow** (see `docs/gitflow.md`).
- `Sentry.init()` follows one rule for repeated calls: the first call wins. See
`docs/repeated-init.md` before you add or change an `init()`.

- Span origins must follow the [trace origin spec](https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/):
`<type>.<category>.<integration_name>.<integration_part>`, at most four parts, no skipped
parts, only `[a-zA-Z0-9_]` within a part. `category` is the span op category
(`auto.queue.kafkajs.producer`, not `auto.kafkajs.producer`). Don't encode tooling like
`otel` or `orchestrion` in the origin.

## Linting & Formatting

- This project uses **Oxlint** and **Oxfmt** — NOT ESLint or Prettier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ sentryTest('captures CLS as a streamed span with source attributes', async ({ ge
const pageloadSpan = await pageloadSpanPromise;

expect(clsSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'ui.webvital.cls' });
expect(clsSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.cls' });
expect(clsSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.cls' });
expect(clsSpan.attributes[SENTRY_EXCLUSIVE_TIME]).toEqual({ type: 'integer', value: 0 });
expect(clsSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ sentryTest(
status: 'ok',
attributes: {
[SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' },
[SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' },
[SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' },
[SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' },
[UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' },
[BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ sentryTest(
status: 'ok',
attributes: {
[SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' },
[SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' },
[SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' },
[SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' },
[UI_COMPONENT_NAME]: { value: 'NavigationLink', type: 'string' },
[BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > nav#navigation > NavigationLink', type: 'string' },
Expand Down Expand Up @@ -192,7 +192,7 @@ sentryTest(
status: 'ok',
attributes: {
[SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' },
[SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' },
[SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' },
[SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' },
[BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > nav#navigation > NavigationLink', type: 'string' },
[SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ sentryTest(
status: 'ok',
attributes: {
[SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' },
[SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' },
[SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' },
[SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' },
[UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' },
[BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ sentryTest(
status: 'ok',
attributes: {
[SENTRY_IS_LOCALHOST]: { value: false, type: 'boolean' },
[SENTRY_ORIGIN]: { value: 'auto.http.browser.inp', type: 'string' },
[SENTRY_ORIGIN]: { value: 'auto.ui.browser.inp', type: 'string' },
[SENTRY_OP]: { value: UI_INTERACTION_CLICK, type: 'string' },
[UI_COMPONENT_NAME]: { value: 'NormalButton', type: 'string' },
[BROWSER_WEB_VITAL_INP_TARGET]: { value: 'body > NormalButton', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ sentryTest('captures INP click as a streamed span', async ({ getLocalTestUrl, pa
const pageloadSpan = await pageloadSpanPromise;

expect(inpSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: UI_INTERACTION_CLICK });
expect(inpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.inp' });
expect(inpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.inp' });
expect(inpSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome'));

// Check the INP span carries the transaction/segment name it belongs to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g
const pageloadSpan = await pageloadSpanPromise;

expect(lcpSpan.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'ui.webvital.lcp' });
expect(lcpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.http.browser.lcp' });
expect(lcpSpan.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ui.browser.lcp' });
expect(lcpSpan.attributes[SENTRY_EXCLUSIVE_TIME]).toEqual({ type: 'integer', value: 0 });
expect(lcpSpan.attributes[USER_AGENT_ORIGINAL]?.value).toEqual(expect.stringContaining('Chrome'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ it('instruments SQL exec operations on Durable Object storage', async ({ signal
expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/exec' });

const sqlSpans = spans.filter(
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.db.cloudflare.durable_object.sql',
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.db.cloudflare.durable_object_sql',
);

expect(sqlSpans).toHaveLength(3);
Expand All @@ -33,7 +33,7 @@ it('instruments SQL exec operations on Durable Object storage', async ({ signal
name: 'CREATE TABLE users',
attributes: expect.objectContaining({
[SENTRY_OP]: { type: 'string', value: 'db.query' },
[SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object.sql' },
[SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object_sql' },
'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' },
'db.operation.name': { type: 'string', value: 'exec' },
'db.query.text': {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ it('captures Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', asy
status: 'ok',
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' },
'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' },
'sentry.origin': { type: 'string', value: 'auto.ai.vercelai' },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l/m: if we want to double down on the "the second part must be a span op category", this should be

Suggested change
'sentry.origin': { type: 'string', value: 'auto.ai.vercelai' },
'sentry.origin': { type: 'string', value: 'auto.gen_ai.vercelai' },

[GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' },
[GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 },
[GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 },
Expand All @@ -40,7 +40,7 @@ it('captures Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', asy
status: 'ok',
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'gen_ai.generate_content' },
'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' },
'sentry.origin': { type: 'string', value: 'auto.ai.vercelai' },
[GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' },
}),
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,5 @@ Deno.test('amqplib instrumentation: orchestrion:amqplib:publish channel produces
assertEquals(publishSpan.name, 'send my-exchange');
assertEquals(publishSpan.attributes['messaging.destination.name']?.value, 'my-exchange');
assertEquals(publishSpan.attributes['messaging.system']?.value, 'rabbitmq');
assertEquals(publishSpan.attributes['sentry.origin']?.value, 'auto.amqplib.publisher');
assertEquals(publishSpan.attributes['sentry.origin']?.value, 'auto.queue.amqplib.publisher');
});
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,5 @@ Deno.test('aws-sdk instrumentation: orchestrion @smithy/smithy-client:send chann
assertEquals(awsSpan.attributes['rpc.service']?.value, 'CloudWatch');
assertEquals(awsSpan.attributes['rpc.method']?.value, 'DescribeAlarms');
assertEquals(awsSpan.attributes['cloud.region']?.value, 'us-east-1');
assertEquals(awsSpan.attributes['sentry.origin']?.value, 'auto.aws.aws_sdk');
assertEquals(awsSpan.attributes['sentry.origin']?.value, 'auto.rpc.aws_sdk');
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,5 @@ Deno.test('firebase instrumentation: orchestrion @firebase/firestore:add-doc cha
assertEquals(fsSpan.attributes['db.namespace']?.value, '[DEFAULT]');
assertEquals(fsSpan.attributes['db.system.name']?.value, 'firebase.firestore');
assertEquals(fsSpan.attributes['firebase.firestore.options.projectId']?.value, 'demo-project');
assertEquals(fsSpan.attributes['sentry.origin']?.value, 'auto.firebase.firestore');
assertEquals(fsSpan.attributes['sentry.origin']?.value, 'auto.db.firebase.firestore');
});
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,5 @@ Deno.test('kafkajs instrumentation: orchestrion:kafkajs:send_batch channel produ
assertEquals(kafkaSpan.name, 'send my-topic');
assertEquals(kafkaSpan.attributes['messaging.system']?.value, 'kafka');
assertEquals(kafkaSpan.attributes['messaging.destination.name']?.value, 'my-topic');
assertEquals(kafkaSpan.attributes['sentry.origin']?.value, 'auto.kafkajs.producer');
assertEquals(kafkaSpan.attributes['sentry.origin']?.value, 'auto.queue.kafkajs.producer');
});
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,5 @@ Deno.test('vercel-ai instrumentation: orchestrion:ai:generateText channel produc
assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gpt-4o');
assertEquals(aiSpan.attributes['vercel.ai.operationId']?.value, 'ai.generateText');
assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15);
assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.vercelai.channel');
assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.vercelai');
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ test.describe('Lambda layer', () => {
data: {
'sentry.sample_rate': 1,
'sentry.segment.name.source': 'component',
'sentry.origin': 'auto.aws_lambda',
'sentry.origin': 'auto.function.aws_lambda',
'sentry.op': 'function.aws',
'cloud.account.id': '012345678912',
'cloud.platform': 'aws_lambda',
Expand All @@ -49,7 +49,7 @@ test.describe('Lambda layer', () => {
'sentry.kind': 'server',
},
op: 'function.aws',
origin: 'auto.aws_lambda',
origin: 'auto.function.aws_lambda',
span_id: expect.stringMatching(/[a-f0-9]{16}/),
status: 'ok',
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
Expand Down Expand Up @@ -108,7 +108,7 @@ test.describe('Lambda layer', () => {
data: {
'sentry.sample_rate': 1,
'sentry.segment.name.source': 'component',
'sentry.origin': 'auto.aws_lambda',
'sentry.origin': 'auto.function.aws_lambda',
'sentry.op': 'function.aws',
'cloud.account.id': '012345678912',
'cloud.platform': 'aws_lambda',
Expand All @@ -120,7 +120,7 @@ test.describe('Lambda layer', () => {
'sentry.kind': 'server',
},
op: 'function.aws',
origin: 'auto.aws_lambda',
origin: 'auto.function.aws_lambda',
span_id: expect.stringMatching(/[a-f0-9]{16}/),
status: 'ok',
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
Expand Down Expand Up @@ -234,7 +234,7 @@ test.describe('Lambda layer', () => {
data: {
'sentry.sample_rate': 1,
'sentry.segment.name.source': 'component',
'sentry.origin': 'auto.aws_lambda',
'sentry.origin': 'auto.function.aws_lambda',
'sentry.op': 'function.aws',
'cloud.account.id': '012345678912',
'cloud.platform': 'aws_lambda',
Expand All @@ -246,7 +246,7 @@ test.describe('Lambda layer', () => {
'sentry.kind': 'server',
},
op: 'function.aws',
origin: 'auto.aws_lambda',
origin: 'auto.function.aws_lambda',
span_id: expect.stringMatching(/[a-f0-9]{16}/),
status: 'ok',
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
Expand Down Expand Up @@ -283,11 +283,11 @@ test.describe('Lambda layer', () => {
expect(transactionEvent.contexts?.trace).toEqual(
expect.objectContaining({
op: 'function.aws',
origin: 'auto.aws_lambda',
origin: 'auto.function.aws_lambda',
status: 'ok',
data: expect.objectContaining({
'sentry.op': 'function.aws',
'sentry.origin': 'auto.aws_lambda',
'sentry.origin': 'auto.function.aws_lambda',
'sentry.kind': 'server',
'cloud.resource_id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerCallback',
'faas.name': 'LayerCallback',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function assertLambdaTrace(spans: SerializedStreamedSpan[], functionName: string

expect(segmentSpan?.attributes).toMatchObject({
'sentry.op': { value: 'function.aws', type: 'string' },
'sentry.origin': { value: 'auto.aws_lambda', type: 'string' },
'sentry.origin': { value: 'auto.function.aws_lambda', type: 'string' },
'sentry.kind': { value: 'server', type: 'string' },
'sentry.segment.name.source': { value: 'component', type: 'string' },
'cloud.account.id': { value: '012345678912', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs
).toEqual([]);
expect(
traceSpans
.filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.vercelai'))
.filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.ai.vercelai'))
.map(describeSpan),
).toEqual([]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ test('captures an error thrown inside a Think tool and marks its span errored',
const exception = error.exception?.values?.[0];
expect(exception?.type).toBe('Error');
expect(exception?.value).toBe('Think tool failed on purpose');
expect(exception?.mechanism?.type).toBe('auto.vercelai.channel');
expect(exception?.mechanism?.type).toBe('auto.ai.vercelai');

const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now');
expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test('captures the invoke_agent / generate_content / execute_tool hierarchy for
const generateContent = spans.find(span => getSpanOp(span) === 'gen_ai.generate_content');
const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool');

expect(attr(invokeAgent, 'sentry.origin')).toBe('auto.vercelai.channel');
expect(attr(invokeAgent, 'sentry.origin')).toBe('auto.ai.vercelai');
expect(attr(invokeAgent, 'gen_ai.operation.name')).toBe('invoke_agent');
// Think passes `this.constructor.name` to the AI SDK as the `functionId`, so this only holds while
// the build-time wrapper leaves the user's class name alone (#24700).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ test('captures Vercel AI v7 spans with nodejs_compat using tracing channels', as
'gen_ai.usage.output_tokens': { value: 20, type: 'integer' },
'gen_ai.usage.total_tokens': { value: 30, type: 'integer' },
'sentry.op': { value: 'gen_ai.invoke_agent', type: 'string' },
'sentry.origin': { value: 'auto.vercelai.channel', type: 'string' },
'sentry.origin': { value: 'auto.ai.vercelai', type: 'string' },
}),
});

Expand All @@ -49,7 +49,7 @@ test('captures Vercel AI v7 spans with nodejs_compat using tracing channels', as
'gen_ai.usage.output_tokens': { value: 20, type: 'integer' },
'gen_ai.usage.total_tokens': { value: 30, type: 'integer' },
'sentry.op': { value: 'gen_ai.generate_content', type: 'string' },
'sentry.origin': { value: 'auto.vercelai.channel', type: 'string' },
'sentry.origin': { value: 'auto.ai.vercelai', type: 'string' },
}),
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ test('captures Vercel AI agent spans (invoke_agent, generate_content, execute_to
const generateContent = traceSpans.find(span => getSpanOp(span) === 'gen_ai.generate_content');
const executeTool = traceSpans.find(span => getSpanOp(span) === 'gen_ai.execute_tool');

expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.vercelai.channel');
expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.ai.vercelai');
expect(invokeAgent?.attributes?.['gen_ai.operation.name']?.value).toBe('invoke_agent');
expect(invokeAgent?.attributes?.['gen_ai.request.model']?.value).toBe('openai/gpt-4o-mini');
expect(invokeAgent?.attributes?.['gen_ai.provider.name']?.value).toBe('openrouter');
Expand Down Expand Up @@ -134,7 +134,7 @@ test('captures errors thrown inside an eve tool', async ({ baseURL }) => {
type: 'Error',
value: expect.stringContaining('Intentional eve tool failure'),
mechanism: {
type: 'auto.vercelai.channel',
type: 'auto.ai.vercelai',
handled: false,
},
},
Expand Down Expand Up @@ -165,7 +165,7 @@ test('captures a gen_ai.decide span for a Jev call inside an eve tool', async ({

expect(decide?.name).toBe('decide typesafe-ai/jev');
expect(decide?.status).toBe('ok');
expect(decide?.attributes?.[SENTRY_ORIGIN]?.value).toBe('auto.vercelai.channel');
expect(decide?.attributes?.[SENTRY_ORIGIN]?.value).toBe('auto.ai.vercelai');
expect(decide?.attributes?.[GEN_AI_OPERATION_NAME]?.value).toBe('decide');
expect(decide?.attributes?.[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe-ai/jev');
expect(decide?.attributes?.[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(275);
Expand Down
Loading
Loading