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
10 changes: 10 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -992,6 +993,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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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' }),
);
});
}
});
33 changes: 29 additions & 4 deletions packages/aws-serverless/src/requestSpanOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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) {
Expand Down
Loading