diff --git a/MIGRATION.md b/MIGRATION.md index 957708758e53..ae5c2d0583d7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -972,6 +972,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` | @@ -999,6 +1000,15 @@ 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.*`. 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 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/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/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..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 @@ -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: 'component', 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' }), - ); - }); + } }); diff --git a/packages/aws-serverless/src/requestSpanOptions.ts b/packages/aws-serverless/src/requestSpanOptions.ts index 80faf9d3c1e9..f13fb0686c8a 100644 --- a/packages/aws-serverless/src/requestSpanOptions.ts +++ b/packages/aws-serverless/src/requestSpanOptions.ts @@ -24,11 +24,18 @@ 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'; import type { SpanAttributes, StartSpanOptions } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, filterCollectedUrl } from '@sentry/core'; +import { + getClient, + hasSpanStreamingEnabled, + 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,25 +50,43 @@ 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', + [SENTRY_ORIGIN]: 'auto.aws_lambda', + [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SENTRY_KIND]: 'server', [ATTR_FAAS_EXECUTION]: context.awsRequestId, [ATTR_FAAS_ID]: context.invokedFunctionArn, [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) {