From d167981831b9ed9caaaf3f9d4cd56b2f8e86b33f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 4 Sep 2026 11:53:04 +0200 Subject: [PATCH 1/4] feat(aws-serverless): Emit low cardinality `function.aws` span names `function.aws` spans were already named after the Lambda function, which is what the `{{faas.name}}` template in the Sentry span name conventions asks for, so their names do not change. What was missing is the conventions' static fallback: when the invocation context carries no function name, the span was started with an empty name and `faas.name` was left unset. Resolve the function name from `context.functionName` or the `AWS_LAMBDA_FUNCTION_NAME` environment variable, use it for both the span name and `faas.name`, and fall back to `Serverless function execution` under span streaming. Without span streaming the name stays byte-identical to before. Refs #23954 Co-Authored-By: Claude Opus 5 (1M context) --- MIGRATION.md | 8 ++ .../aws-serverless/src/requestSpanOptions.ts | 29 ++++- .../test/requestSpanOptions.test.ts | 113 ++++++++++++++++++ 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 packages/aws-serverless/test/requestSpanOptions.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index a925ba77ef22..4db2e15e49ae 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -965,6 +965,7 @@ The following span names were adjusted: | `router` | Framework-specific, sometimes containing the raw URL | `/users/123`, `SvelteKit Route Change` | The span's `http.route`, or `Router` if the SDK has none | `/users/:id`, `Router` | | `handler` | Framework-specific, often carrying the request method | `GET /users/:id`, `route-handler`, `getUser` | The span's `http.route`, or `Request handler` if the SDK has none | `/users/:id`, `Request handler` | | `function.gcp` | The request method and path for HTTP functions, otherwise the trigger's event or trigger type | `POST /users`, `google.pubsub.topic.publish`, `firebase.function.http.request` | The function name, or `Serverless function execution` if the SDK cannot resolve one | `myFunction`, `Serverless function execution` | +| `function.aws` | The Lambda function name | `my-function` | Unchanged, except that the SDK now falls back to `Serverless function execution` if it cannot resolve the function name | `my-function`, `Serverless function execution` | | `graphql` | The graphql phase and, for operations, the operation name | `query GetUser`, `graphql.parse`, `graphql.resolve user.0.name` | The operation type, or the processing type where there is none | `GraphQL query`, `GraphQL parse`, `GraphQL resolve` | | `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing | `chat gpt-4`, `chat unknown` | `{operation} {model}`, or `{operation}` if the model is missing | `chat gpt-4`, `chat` | | `gen_ai.invoke_agent` | The LangChain chain name, prefixed with `chain` rather than the operation | `chain format_prompt`, `chain unknown_chain` | `{operation} {name}`, where the name is the span's `gen_ai.agent.name`, `gen_ai.pipeline.name` or `gen_ai.function_id`, in that order, or `{operation}` if the span carries none | `invoke_agent format_prompt`, `invoke_agent` | @@ -992,6 +993,13 @@ Whatever the name no longer carries stays on the span as an attribute: - `gcp.function.context.*` — the fields of the trigger event, including the event type the span used to be named after. - `http.request.method` and `url.path` — for HTTP-triggered functions, the method and path the span used to be named after. +`function.aws` spans in `@sentry/aws-serverless` were already named after the Lambda function, so +their names are unchanged. The only new behaviour is the fallback: if neither the invocation context +nor the `AWS_LAMBDA_FUNCTION_NAME` environment variable yields a function name, the span is named +`Serverless function execution` instead of carrying an empty name. These spans continue to carry the +function name on `faas.name`, the request URL on `url.full`, and the invocation details on +`aws.lambda.*` and `aws.cloudwatch.logs.*`. + #### Filtering and sampling When span streaming is enabled (i.e. by default) `ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name: diff --git a/packages/aws-serverless/src/requestSpanOptions.ts b/packages/aws-serverless/src/requestSpanOptions.ts index 80faf9d3c1e9..565d6437ce07 100644 --- a/packages/aws-serverless/src/requestSpanOptions.ts +++ b/packages/aws-serverless/src/requestSpanOptions.ts @@ -28,7 +28,13 @@ import { } from '@sentry/conventions/attributes'; import { FUNCTION_AWS } from '@sentry/conventions/op'; import type { SpanAttributes, StartSpanOptions } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, filterCollectedUrl } from '@sentry/core'; +import { + getClient, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, + filterCollectedUrl, +} from '@sentry/core'; import type { Context } from 'aws-lambda'; import { ATTR_FAAS_EXECUTION, ATTR_FAAS_ID } from './semconv'; @@ -43,9 +49,16 @@ interface ApiGatewayLikeEvent { * Builds the options for the `function.aws` transaction started for each invocation. */ export function getRequestSpanOptions(event: unknown, context: Context, requestIsColdStart: boolean): StartSpanOptions { + const client = getClient(); + + const functionName = getFunctionName(context); + // The span is started within the surrounding `continueTrace`, so it continues the incoming trace. return { - name: context.functionName, + name: + client && hasSpanStreamingEnabled(client) + ? functionName || SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK + : context.functionName, attributes: { [SENTRY_OP]: FUNCTION_AWS, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.aws_lambda', @@ -55,13 +68,23 @@ export function getRequestSpanOptions(event: unknown, context: Context, requestI [CLOUD_ACCOUNT_ID]: extractAccountId(context.invokedFunctionArn), [CLOUD_PROVIDER]: 'aws', [CLOUD_PLATFORM]: 'aws_lambda', - [FAAS_NAME]: context.functionName, + [FAAS_NAME]: functionName, [FAAS_COLDSTART]: requestIsColdStart, ...extractOtherEventFields(event), }, }; } +/** + * Resolves the name of the currently executing Lambda function. + * + * The runtime always populates `context.functionName`; `AWS_LAMBDA_FUNCTION_NAME` covers custom + * runtimes and local emulators that only partially fill in the invocation context. + */ +function getFunctionName(context: Context): string | undefined { + return context.functionName || process.env.AWS_LAMBDA_FUNCTION_NAME || undefined; +} + function extractAccountId(arn: string): string | undefined { const parts = arn.split(':'); if (parts.length >= 5) { diff --git a/packages/aws-serverless/test/requestSpanOptions.test.ts b/packages/aws-serverless/test/requestSpanOptions.test.ts new file mode 100644 index 000000000000..3999b87896e7 --- /dev/null +++ b/packages/aws-serverless/test/requestSpanOptions.test.ts @@ -0,0 +1,113 @@ +import { + CLOUD_ACCOUNT_ID, + CLOUD_PLATFORM, + CLOUD_PROVIDER, + FAAS_COLDSTART, + FAAS_NAME, + SENTRY_KIND, + SENTRY_OP, + URL_FULL, +} from '@sentry/conventions/attributes'; +import { FUNCTION_AWS } from '@sentry/conventions/op'; +import type { Client } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK } from '@sentry/core'; +import type { Context } from 'aws-lambda'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { getRequestSpanOptions } from '../src/requestSpanOptions'; + +function createContext(overrides: Partial = {}): Context { + return { + functionName: 'my-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:012345678912:function:my-function', + awsRequestId: '1e1cd0dc-6bd0-4e0e-9a5d-63e8c7bd4b3b', + ...overrides, + } as Context; +} + +function mockSpanStreaming(enabled: boolean): void { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: enabled ? 'stream' : 'static' }), + } as unknown as Client); +} + +describe('getRequestSpanOptions', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + test('names the span after the function and sets the invocation attributes', () => { + mockSpanStreaming(false); + + expect(getRequestSpanOptions({}, createContext(), true)).toEqual({ + name: 'my-function', + attributes: { + [SENTRY_OP]: FUNCTION_AWS, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.aws_lambda', + [SENTRY_KIND]: 'server', + 'faas.execution': '1e1cd0dc-6bd0-4e0e-9a5d-63e8c7bd4b3b', + 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:my-function', + [CLOUD_ACCOUNT_ID]: '012345678912', + [CLOUD_PROVIDER]: 'aws', + [CLOUD_PLATFORM]: 'aws_lambda', + [FAAS_NAME]: 'my-function', + [FAAS_COLDSTART]: true, + }, + }); + }); + + test.each([true, false])('names the span after the function with span streaming %s', spanStreamingEnabled => { + mockSpanStreaming(spanStreamingEnabled); + + const spanOptions = getRequestSpanOptions({}, createContext(), false); + + expect(spanOptions.name).toBe('my-function'); + expect(spanOptions.attributes?.[FAAS_NAME]).toBe('my-function'); + }); + + test('keeps the API gateway URL on an attribute rather than in the name', () => { + mockSpanStreaming(true); + + const event = { + headers: { host: 'api.example.com', 'x-forwarded-proto': 'https' }, + path: '/users/123', + queryStringParameters: { expand: 'profile' }, + }; + + const spanOptions = getRequestSpanOptions(event, createContext(), false); + + expect(spanOptions.name).toBe('my-function'); + expect(spanOptions.attributes?.[URL_FULL]).toBe('https://api.example.com/users/123?expand=profile'); + }); + + test('falls back to AWS_LAMBDA_FUNCTION_NAME when the context has no function name', () => { + mockSpanStreaming(true); + vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', 'my-env-function'); + + const spanOptions = getRequestSpanOptions({}, createContext({ functionName: '' }), false); + + expect(spanOptions.name).toBe('my-env-function'); + expect(spanOptions.attributes?.[FAAS_NAME]).toBe('my-env-function'); + }); + + test('falls back to the static span name when no function name is resolvable', () => { + mockSpanStreaming(true); + vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', ''); + + const spanOptions = getRequestSpanOptions({}, createContext({ functionName: '' }), false); + + expect(spanOptions.name).toBe(SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK); + expect(spanOptions.attributes?.[FAAS_NAME]).toBeUndefined(); + }); + + test('keeps the unresolved function name without span streaming', () => { + mockSpanStreaming(false); + vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', ''); + + const spanOptions = getRequestSpanOptions({}, createContext({ functionName: '' }), false); + + expect(spanOptions.name).toBe(''); + }); +}); From 7b87cfd9691ac25ba1accefb40e73062a975d36f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 4 Sep 2026 12:33:13 +0200 Subject: [PATCH 2/4] convert e2e test to streaming --- .../aws-serverless/src/stack.ts | 1 - .../aws-serverless/tests/npm.test.ts | 208 ++++++------------ 2 files changed, 73 insertions(+), 136 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts index b01f79c5bb48..a2a201bd9486 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -123,7 +123,6 @@ export class LocalLambdaStack extends Stack { SENTRY_DSN: dsn, SENTRY_TRACES_SAMPLE_RATE: 1.0, SENTRY_DEBUG: true, - SENTRY_TRACE_LIFECYCLE: 'static', NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`, }, }, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts index a1d4598a9d19..28fac2db20a3 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts @@ -1,147 +1,85 @@ -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { InvokeCommand } from '@aws-sdk/client-lambda'; import { test, expect } from './lambda-fixtures'; -test.describe('NPM package', () => { - test('tracing in CJS works', async ({ lambdaClient }) => { - const transactionEventPromise = waitForTransaction('aws-serverless', transactionEvent => { - return transactionEvent?.transaction === 'NpmTracingCjs'; - }); - - await lambdaClient.send( - new InvokeCommand({ - FunctionName: 'NpmTracingCjs', - Payload: JSON.stringify({}), - }), - ); - - const transactionEvent = await transactionEventPromise; - - // shows the SDK sent a transaction - expect(transactionEvent.transaction).toEqual('NpmTracingCjs'); // name should be the function name - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'custom', - 'sentry.origin': 'auto.aws_lambda', - 'sentry.op': 'function.aws', - 'cloud.account.id': '012345678912', - 'cloud.platform': 'aws_lambda', - 'cloud.provider': 'aws', - 'faas.execution': expect.any(String), - 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:NpmTracingCjs', - 'faas.name': 'NpmTracingCjs', - 'faas.coldstart': true, - 'sentry.kind': 'server', - }, - op: 'function.aws', - origin: 'auto.aws_lambda', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - - expect(transactionEvent.spans).toHaveLength(2); +// This app runs with `traceLifecycle: 'stream'`, the SDK default. The `aws-serverless-layer` app +// covers the `'static'` lifecycle, so between the two both lifecycles stay under test. + +function assertLambdaTrace(spans: SerializedStreamedSpan[], functionName: string): void { + const segmentSpan = spans.find(span => span.is_segment); + + // `function.aws` span names are low cardinality: the function name, never the invocation URL. + expect(segmentSpan?.name).toBe(functionName); + expect(segmentSpan?.status).toBe('ok'); + expect(getSpanOp(segmentSpan!)).toBe('function.aws'); + + expect(segmentSpan?.attributes).toMatchObject({ + 'sentry.op': { value: 'function.aws', type: 'string' }, + 'sentry.origin': { value: 'auto.aws_lambda', type: 'string' }, + 'sentry.kind': { value: 'server', type: 'string' }, + 'sentry.segment.name.source': { value: 'custom', type: 'string' }, + 'cloud.account.id': { value: '012345678912', type: 'string' }, + 'cloud.platform': { value: 'aws_lambda', type: 'string' }, + 'cloud.provider': { value: 'aws', type: 'string' }, + 'faas.coldstart': { value: true, type: 'boolean' }, + 'faas.execution': { value: expect.any(String), type: 'string' }, + 'faas.id': { value: `arn:aws:lambda:us-east-1:012345678912:function:${functionName}`, type: 'string' }, + // The name the span is named after also stays on the span, so it survives a rename. + 'faas.name': { value: functionName, type: 'string' }, + // Streamed spans have no event contexts, so the `aws.lambda` context the transaction used to + // carry is stamped onto the segment span by `awsLambdaIntegration`. + 'aws.lambda.function_name': { value: functionName, type: 'string' }, + 'aws.lambda.invoked_function_arn': { + value: `arn:aws:lambda:us-east-1:012345678912:function:${functionName}`, + type: 'string', + }, + 'aws.lambda.aws_request_id': { value: expect.any(String), type: 'string' }, + 'aws.cloudwatch.logs.log_group': { value: expect.any(String), type: 'string' }, + 'aws.cloudwatch.logs.log_stream': { value: expect.any(String), type: 'string' }, + }); - // shows that the Otel Http instrumentation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.client', - 'url.full': 'http://example.com/', - }), - description: 'GET http://example.com/', - op: 'http.client', + // shows that the Otel Http instrumentation is working + expect(spans).toContainEqual( + expect.objectContaining({ + name: 'GET example.com', + parent_span_id: segmentSpan?.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.client', type: 'string' }, + 'url.full': { value: 'http://example.com/', type: 'string' }, }), - ); - - // shows that the manual span creation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'manual', - 'sentry.origin': 'manual', - }), - description: 'manual-span', - op: 'manual', + }), + ); + + // shows that the manual span creation is working + expect(spans).toContainEqual( + expect.objectContaining({ + name: 'manual-span', + parent_span_id: segmentSpan?.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'manual', type: 'string' }, + 'sentry.origin': { value: 'manual', type: 'string' }, }), - ); - - // shows that the SDK source is correctly detected - expect(transactionEvent.sdk?.packages).toContainEqual( - expect.objectContaining({ name: 'npm:@sentry/aws-serverless' }), - ); - }); + }), + ); +} - test('tracing in ESM works', async ({ lambdaClient }) => { - const transactionEventPromise = waitForTransaction('aws-serverless', transactionEvent => { - return transactionEvent?.transaction === 'NpmTracingEsm'; - }); +test.describe('NPM package', () => { + for (const [label, functionName] of [ + ['CJS', 'NpmTracingCjs'], + ['ESM', 'NpmTracingEsm'], + ] as const) { + test(`tracing in ${label} works`, async ({ lambdaClient }) => { + const spansPromise = collectStreamedSpans('aws-serverless', spansOfTrace => + spansOfTrace.some(span => span.is_segment && span.name === functionName), + ); - await lambdaClient.send( - new InvokeCommand({ - FunctionName: 'NpmTracingEsm', - Payload: JSON.stringify({}), - }), - ); + await lambdaClient.send(new InvokeCommand({ FunctionName: functionName, Payload: JSON.stringify({}) })); - const transactionEvent = await transactionEventPromise; + const spans = await spansPromise; - // shows the SDK sent a transaction - expect(transactionEvent.transaction).toEqual('NpmTracingEsm'); // name should be the function name - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'custom', - 'sentry.origin': 'auto.aws_lambda', - 'sentry.op': 'function.aws', - 'cloud.account.id': '012345678912', - 'cloud.platform': 'aws_lambda', - 'cloud.provider': 'aws', - 'faas.execution': expect.any(String), - 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:NpmTracingEsm', - 'faas.name': 'NpmTracingEsm', - 'faas.coldstart': true, - 'sentry.kind': 'server', - }, - op: 'function.aws', - origin: 'auto.aws_lambda', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + assertLambdaTrace(spans, functionName); }); - - expect(transactionEvent.spans).toHaveLength(2); - - // shows that the Otel Http instrumentation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.client', - 'url.full': 'http://example.com/', - }), - description: 'GET http://example.com/', - op: 'http.client', - }), - ); - - // shows that the manual span creation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'manual', - 'sentry.origin': 'manual', - }), - description: 'manual-span', - op: 'manual', - }), - ); - - // shows that the SDK source is correctly detected - expect(transactionEvent.sdk?.packages).toContainEqual( - expect.objectContaining({ name: 'npm:@sentry/aws-serverless' }), - ); - }); + } }); From 7b635c07b40a5d3b98efc3f2a8271450872a6eb4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 4 Sep 2026 13:22:54 +0200 Subject: [PATCH 3/4] test(aws-serverless): Drop unit tests covered by the streamed e2e suite `requestSpanOptions.test.ts` asserted the start-span options object against a mocked client. The `aws-serverless` e2e app now runs on span streaming and covers the same ground against a real Lambda, so the unit tests only duplicated it. The one branch e2e cannot reach is the `Serverless function execution` fallback: Lambda always populates `context.functionName`. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/requestSpanOptions.test.ts | 113 ------------------ 1 file changed, 113 deletions(-) delete mode 100644 packages/aws-serverless/test/requestSpanOptions.test.ts diff --git a/packages/aws-serverless/test/requestSpanOptions.test.ts b/packages/aws-serverless/test/requestSpanOptions.test.ts deleted file mode 100644 index 3999b87896e7..000000000000 --- a/packages/aws-serverless/test/requestSpanOptions.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - CLOUD_ACCOUNT_ID, - CLOUD_PLATFORM, - CLOUD_PROVIDER, - FAAS_COLDSTART, - FAAS_NAME, - SENTRY_KIND, - SENTRY_OP, - URL_FULL, -} from '@sentry/conventions/attributes'; -import { FUNCTION_AWS } from '@sentry/conventions/op'; -import type { Client } from '@sentry/core'; -import * as SentryCore from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK } from '@sentry/core'; -import type { Context } from 'aws-lambda'; -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { getRequestSpanOptions } from '../src/requestSpanOptions'; - -function createContext(overrides: Partial = {}): Context { - return { - functionName: 'my-function', - functionVersion: '$LATEST', - invokedFunctionArn: 'arn:aws:lambda:us-east-1:012345678912:function:my-function', - awsRequestId: '1e1cd0dc-6bd0-4e0e-9a5d-63e8c7bd4b3b', - ...overrides, - } as Context; -} - -function mockSpanStreaming(enabled: boolean): void { - vi.spyOn(SentryCore, 'getClient').mockReturnValue({ - getOptions: () => ({ traceLifecycle: enabled ? 'stream' : 'static' }), - } as unknown as Client); -} - -describe('getRequestSpanOptions', () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - - test('names the span after the function and sets the invocation attributes', () => { - mockSpanStreaming(false); - - expect(getRequestSpanOptions({}, createContext(), true)).toEqual({ - name: 'my-function', - attributes: { - [SENTRY_OP]: FUNCTION_AWS, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.aws_lambda', - [SENTRY_KIND]: 'server', - 'faas.execution': '1e1cd0dc-6bd0-4e0e-9a5d-63e8c7bd4b3b', - 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:my-function', - [CLOUD_ACCOUNT_ID]: '012345678912', - [CLOUD_PROVIDER]: 'aws', - [CLOUD_PLATFORM]: 'aws_lambda', - [FAAS_NAME]: 'my-function', - [FAAS_COLDSTART]: true, - }, - }); - }); - - test.each([true, false])('names the span after the function with span streaming %s', spanStreamingEnabled => { - mockSpanStreaming(spanStreamingEnabled); - - const spanOptions = getRequestSpanOptions({}, createContext(), false); - - expect(spanOptions.name).toBe('my-function'); - expect(spanOptions.attributes?.[FAAS_NAME]).toBe('my-function'); - }); - - test('keeps the API gateway URL on an attribute rather than in the name', () => { - mockSpanStreaming(true); - - const event = { - headers: { host: 'api.example.com', 'x-forwarded-proto': 'https' }, - path: '/users/123', - queryStringParameters: { expand: 'profile' }, - }; - - const spanOptions = getRequestSpanOptions(event, createContext(), false); - - expect(spanOptions.name).toBe('my-function'); - expect(spanOptions.attributes?.[URL_FULL]).toBe('https://api.example.com/users/123?expand=profile'); - }); - - test('falls back to AWS_LAMBDA_FUNCTION_NAME when the context has no function name', () => { - mockSpanStreaming(true); - vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', 'my-env-function'); - - const spanOptions = getRequestSpanOptions({}, createContext({ functionName: '' }), false); - - expect(spanOptions.name).toBe('my-env-function'); - expect(spanOptions.attributes?.[FAAS_NAME]).toBe('my-env-function'); - }); - - test('falls back to the static span name when no function name is resolvable', () => { - mockSpanStreaming(true); - vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', ''); - - const spanOptions = getRequestSpanOptions({}, createContext({ functionName: '' }), false); - - expect(spanOptions.name).toBe(SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK); - expect(spanOptions.attributes?.[FAAS_NAME]).toBeUndefined(); - }); - - test('keeps the unresolved function name without span streaming', () => { - mockSpanStreaming(false); - vi.stubEnv('AWS_LAMBDA_FUNCTION_NAME', ''); - - const spanOptions = getRequestSpanOptions({}, createContext({ functionName: '' }), false); - - expect(spanOptions.name).toBe(''); - }); -}); From 4e43ca6b28e4e44e94e29b5a838b30fac9d398d2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 4 Sep 2026 14:06:50 +0200 Subject: [PATCH 4/4] feat(aws-serverless): Set `component` as the `function.aws` segment name source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `function.aws` spans left `sentry.segment.name.source` unset, so it defaulted to `custom` — which claims the user named the span. The name comes from the Lambda function, so `component` is what it is, and it matches the other FaaS spans. Applies in both trace lifecycles, so the static layer suite and the streamed npm suite are updated alongside it. Refs #23954 Co-Authored-By: Claude Opus 5 (1M context) --- MIGRATION.md | 4 +++- .../aws-serverless-layer/tests/layer.test.ts | 6 +++--- .../test-applications/aws-serverless/tests/npm.test.ts | 2 +- packages/aws-serverless/src/requestSpanOptions.ts | 6 ++++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 4db2e15e49ae..7ebb3844b925 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -998,7 +998,9 @@ their names are unchanged. The only new behaviour is the fallback: if neither th nor the `AWS_LAMBDA_FUNCTION_NAME` environment variable yields a function name, the span is named `Serverless function execution` instead of carrying an empty name. These spans continue to carry the function name on `faas.name`, the request URL on `url.full`, and the invocation details on -`aws.lambda.*` and `aws.cloudwatch.logs.*`. +`aws.lambda.*` and `aws.cloudwatch.logs.*`. Their `sentry.segment.name.source` is now `component` +rather than `custom`, matching the other FaaS spans: the name comes from the function, not from the +user. This applies in both trace lifecycles. #### Filtering and sampling diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts index 525e51b01136..da798f99a9fc 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts @@ -36,7 +36,7 @@ test.describe('Lambda layer', () => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'custom', + 'sentry.segment.name.source': 'component', 'sentry.origin': 'auto.aws_lambda', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', @@ -107,7 +107,7 @@ test.describe('Lambda layer', () => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'custom', + 'sentry.segment.name.source': 'component', 'sentry.origin': 'auto.aws_lambda', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', @@ -233,7 +233,7 @@ test.describe('Lambda layer', () => { expect(transactionEvent.contexts?.trace).toEqual({ data: { 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'custom', + 'sentry.segment.name.source': 'component', 'sentry.origin': 'auto.aws_lambda', 'sentry.op': 'function.aws', 'cloud.account.id': '012345678912', diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts index 28fac2db20a3..96b11fd5c1af 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts @@ -18,7 +18,7 @@ function assertLambdaTrace(spans: SerializedStreamedSpan[], functionName: string 'sentry.op': { value: 'function.aws', type: 'string' }, 'sentry.origin': { value: 'auto.aws_lambda', type: 'string' }, 'sentry.kind': { value: 'server', type: 'string' }, - 'sentry.segment.name.source': { value: 'custom', type: 'string' }, + 'sentry.segment.name.source': { value: 'component', type: 'string' }, 'cloud.account.id': { value: '012345678912', type: 'string' }, 'cloud.platform': { value: 'aws_lambda', type: 'string' }, 'cloud.provider': { value: 'aws', type: 'string' }, diff --git a/packages/aws-serverless/src/requestSpanOptions.ts b/packages/aws-serverless/src/requestSpanOptions.ts index 565d6437ce07..f13fb0686c8a 100644 --- a/packages/aws-serverless/src/requestSpanOptions.ts +++ b/packages/aws-serverless/src/requestSpanOptions.ts @@ -24,6 +24,8 @@ import { FAAS_NAME, SENTRY_KIND, SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, } from '@sentry/conventions/attributes'; import { FUNCTION_AWS } from '@sentry/conventions/op'; @@ -31,7 +33,6 @@ import type { SpanAttributes, StartSpanOptions } from '@sentry/core'; import { getClient, hasSpanStreamingEnabled, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, filterCollectedUrl, } from '@sentry/core'; @@ -61,7 +62,8 @@ export function getRequestSpanOptions(event: unknown, context: Context, requestI : context.functionName, attributes: { [SENTRY_OP]: FUNCTION_AWS, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.aws_lambda', + [SENTRY_ORIGIN]: 'auto.aws_lambda', + [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SENTRY_KIND]: 'server', [ATTR_FAAS_EXECUTION]: context.awsRequestId, [ATTR_FAAS_ID]: context.invokedFunctionArn,