Skip to content

Commit 96d7884

Browse files
authored
feat(serverless): Add server-only context span attributes via processSegmentSpan hooks (#20842)
- Converts server-only scope contexts to segment span attributes for span streaming, using processSegmentSpan hooks in their respective packages (so we avoid bundle size impact) - `aws-serverless`: Adds `processSegmentSpan` to `awsLambdaIntegration` for aws attributes - `google-cloud-serverless`: New `gcpContextIntegration` for gcp attributes Server side part of #20828 closes #20385
1 parent e65c76c commit 96d7884

5 files changed

Lines changed: 306 additions & 2 deletions

File tree

packages/aws-serverless/src/integration/awslambda.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { IntegrationFn } from '@sentry/core';
2-
import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
2+
import {
3+
defineIntegration,
4+
getCurrentScope,
5+
safeSetSpanJSONAttributes,
6+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
7+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
8+
} from '@sentry/core';
39
import { captureException, generateInstrumentOnce } from '@sentry/node';
410
import { eventContextExtractor, markEventUnhandled } from '../utils';
511
import { AwsLambdaInstrumentation } from './instrumentation-aws-lambda/instrumentation';
@@ -36,12 +42,50 @@ export const instrumentAwsLambda = generateInstrumentOnce(
3642
},
3743
);
3844

45+
const AWS_LAMBDA_CONTEXT_FIELDS = [
46+
'aws_request_id',
47+
'function_name',
48+
'function_version',
49+
'invoked_function_arn',
50+
'execution_duration_in_millis',
51+
'remaining_time_in_millis',
52+
] as const;
53+
54+
const AWS_CLOUDWATCH_CONTEXT_FIELDS = ['log_group', 'log_stream', 'url'] as const;
55+
3956
const _awsLambdaIntegration = ((options: AwsLambdaOptions = {}) => {
4057
return {
4158
name: 'AwsLambda',
4259
setupOnce() {
4360
instrumentAwsLambda(options);
4461
},
62+
processSegmentSpan(span) {
63+
const { contexts } = getCurrentScope().getScopeData();
64+
65+
const awsLambda = contexts['aws.lambda'];
66+
if (awsLambda) {
67+
const attrs: Record<string, unknown> = {};
68+
for (const field of AWS_LAMBDA_CONTEXT_FIELDS) {
69+
const value = awsLambda[field];
70+
if (typeof value === 'string' || typeof value === 'number') {
71+
attrs[`aws.lambda.${field}`] = value;
72+
}
73+
}
74+
safeSetSpanJSONAttributes(span, attrs);
75+
}
76+
77+
const awsCloudwatch = contexts['aws.cloudwatch.logs'];
78+
if (awsCloudwatch) {
79+
const attrs: Record<string, unknown> = {};
80+
for (const field of AWS_CLOUDWATCH_CONTEXT_FIELDS) {
81+
const value = awsCloudwatch[field];
82+
if (typeof value === 'string' || typeof value === 'number') {
83+
attrs[`aws.cloudwatch.logs.${field}`] = value;
84+
}
85+
}
86+
safeSetSpanJSONAttributes(span, attrs);
87+
}
88+
},
4589
};
4690
}) satisfies IntegrationFn;
4791

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { StreamedSpanJSON } from '@sentry/core';
2+
import { describe, expect, test, vi } from 'vitest';
3+
import { awsLambdaIntegration } from '../src/integration/awslambda';
4+
5+
const mockGetScopeData = vi.fn();
6+
7+
vi.mock('@sentry/core', async () => {
8+
const original = await vi.importActual('@sentry/core');
9+
return {
10+
...original,
11+
getCurrentScope: () => ({
12+
getScopeData: mockGetScopeData,
13+
}),
14+
};
15+
});
16+
17+
vi.mock('@sentry/node', async () => {
18+
const original = await vi.importActual('@sentry/node');
19+
return {
20+
...original,
21+
generateInstrumentOnce: () => () => {},
22+
};
23+
});
24+
25+
describe('awsLambdaIntegration processSegmentSpan', () => {
26+
function makeSpanJSON(): StreamedSpanJSON {
27+
return {
28+
name: 'test',
29+
span_id: 'abc',
30+
trace_id: 'def',
31+
start_timestamp: 0,
32+
end_timestamp: 1,
33+
status: 'ok',
34+
is_segment: true,
35+
attributes: {},
36+
};
37+
}
38+
39+
test('maps aws.lambda context fields to segment span attributes', () => {
40+
mockGetScopeData.mockReturnValue({
41+
contexts: {
42+
'aws.lambda': {
43+
aws_request_id: 'req-123',
44+
function_name: 'my-function',
45+
function_version: '$LATEST',
46+
invoked_function_arn: 'arn:aws:lambda:us-east-1:123:function:my-function',
47+
execution_duration_in_millis: 150,
48+
remaining_time_in_millis: 2850,
49+
'sys.argv': ['/usr/bin/node', '--secret=abc'],
50+
},
51+
},
52+
});
53+
54+
const integration = awsLambdaIntegration();
55+
const span = makeSpanJSON();
56+
integration.processSegmentSpan!(span, {} as any);
57+
58+
expect(span.attributes).toEqual(
59+
expect.objectContaining({
60+
'aws.lambda.aws_request_id': 'req-123',
61+
'aws.lambda.function_name': 'my-function',
62+
'aws.lambda.function_version': '$LATEST',
63+
'aws.lambda.invoked_function_arn': 'arn:aws:lambda:us-east-1:123:function:my-function',
64+
'aws.lambda.execution_duration_in_millis': 150,
65+
'aws.lambda.remaining_time_in_millis': 2850,
66+
}),
67+
);
68+
expect(span.attributes).not.toHaveProperty('aws.lambda.sys.argv');
69+
});
70+
71+
test('maps aws.cloudwatch.logs context fields to segment span attributes', () => {
72+
mockGetScopeData.mockReturnValue({
73+
contexts: {
74+
'aws.cloudwatch.logs': {
75+
log_group: '/aws/lambda/my-function',
76+
log_stream: '2024/01/01/[$LATEST]abc123',
77+
url: 'https://console.aws.amazon.com/cloudwatch/home',
78+
},
79+
},
80+
});
81+
82+
const integration = awsLambdaIntegration();
83+
const span = makeSpanJSON();
84+
integration.processSegmentSpan!(span, {} as any);
85+
86+
expect(span.attributes).toEqual(
87+
expect.objectContaining({
88+
'aws.cloudwatch.logs.log_group': '/aws/lambda/my-function',
89+
'aws.cloudwatch.logs.log_stream': '2024/01/01/[$LATEST]abc123',
90+
'aws.cloudwatch.logs.url': 'https://console.aws.amazon.com/cloudwatch/home',
91+
}),
92+
);
93+
});
94+
95+
test('does nothing when no aws contexts are set', () => {
96+
mockGetScopeData.mockReturnValue({ contexts: {} });
97+
98+
const integration = awsLambdaIntegration();
99+
const span = makeSpanJSON();
100+
integration.processSegmentSpan!(span, {} as any);
101+
102+
expect(span.attributes).toEqual({});
103+
});
104+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { IntegrationFn } from '@sentry/core';
2+
import { defineIntegration, getCurrentScope, safeSetSpanJSONAttributes } from '@sentry/core';
3+
4+
const GCP_CONTEXT_ATTRIBUTE_MAP: Record<string, string> = {
5+
type: 'gcp.function.context.type',
6+
source: 'gcp.function.context.source',
7+
id: 'gcp.function.context.id',
8+
specversion: 'gcp.function.context.specversion',
9+
time: 'gcp.function.context.time',
10+
eventId: 'gcp.function.context.event_id',
11+
timestamp: 'gcp.function.context.timestamp',
12+
eventType: 'gcp.function.context.event_type',
13+
resource: 'gcp.function.context.resource',
14+
};
15+
16+
const _gcpContextIntegration = (() => {
17+
return {
18+
name: 'GcpContext',
19+
processSegmentSpan(span) {
20+
const gcpContext = getCurrentScope().getScopeData().contexts['gcp.function.context'];
21+
if (!gcpContext) {
22+
return;
23+
}
24+
25+
const attrs: Record<string, unknown> = {};
26+
for (const [field, attrName] of Object.entries(GCP_CONTEXT_ATTRIBUTE_MAP)) {
27+
const value = gcpContext[field];
28+
if (typeof value === 'string' || typeof value === 'number') {
29+
attrs[attrName] = value;
30+
}
31+
}
32+
safeSetSpanJSONAttributes(span, attrs);
33+
},
34+
};
35+
}) satisfies IntegrationFn;
36+
37+
export const gcpContextIntegration = defineIntegration(_gcpContextIntegration);

packages/google-cloud-serverless/src/sdk.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { applySdkMetadata } from '@sentry/core';
33
import type { NodeClient, NodeOptions } from '@sentry/node';
44
import { getDefaultIntegrationsWithoutPerformance, init as initNode } from '@sentry/node';
55
import { isCjs } from '@sentry/node-core';
6+
import { gcpContextIntegration } from './integrations/gcp-context';
67
import { googleCloudGrpcIntegration } from './integrations/google-cloud-grpc';
78
import { googleCloudHttpIntegration } from './integrations/google-cloud-http';
89

@@ -17,7 +18,7 @@ function getCjsOnlyIntegrations(): Integration[] {
1718

1819
/** Get the default integrations for the GCP SDK. */
1920
export function getDefaultIntegrations(_options: Options): Integration[] {
20-
return [...getDefaultIntegrationsWithoutPerformance(), ...getCjsOnlyIntegrations()];
21+
return [...getDefaultIntegrationsWithoutPerformance(), gcpContextIntegration(), ...getCjsOnlyIntegrations()];
2122
}
2223

2324
/**
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { StreamedSpanJSON } from '@sentry/core';
2+
import { describe, expect, test, vi } from 'vitest';
3+
import { gcpContextIntegration } from '../../src/integrations/gcp-context';
4+
5+
const mockGetScopeData = vi.fn();
6+
7+
vi.mock('@sentry/core', async () => {
8+
const original = await vi.importActual('@sentry/core');
9+
return {
10+
...original,
11+
getCurrentScope: () => ({
12+
getScopeData: mockGetScopeData,
13+
}),
14+
};
15+
});
16+
17+
describe('gcpContextIntegration', () => {
18+
function makeSpanJSON(): StreamedSpanJSON {
19+
return {
20+
name: 'test',
21+
span_id: 'abc',
22+
trace_id: 'def',
23+
start_timestamp: 0,
24+
end_timestamp: 1,
25+
status: 'ok',
26+
is_segment: true,
27+
attributes: {},
28+
};
29+
}
30+
31+
test('maps CloudEvents context fields to segment span attributes', () => {
32+
mockGetScopeData.mockReturnValue({
33+
contexts: {
34+
'gcp.function.context': {
35+
type: 'google.cloud.pubsub.topic.v1.messagePublished',
36+
source: '//pubsub.googleapis.com/projects/my-project/topics/my-topic',
37+
id: 'evt-123',
38+
specversion: '1.0',
39+
time: '2024-01-01T00:00:00Z',
40+
},
41+
},
42+
});
43+
44+
const integration = gcpContextIntegration();
45+
const span = makeSpanJSON();
46+
integration.processSegmentSpan!(span, {} as any);
47+
48+
expect(span.attributes).toEqual(
49+
expect.objectContaining({
50+
'gcp.function.context.type': 'google.cloud.pubsub.topic.v1.messagePublished',
51+
'gcp.function.context.source': '//pubsub.googleapis.com/projects/my-project/topics/my-topic',
52+
'gcp.function.context.id': 'evt-123',
53+
'gcp.function.context.specversion': '1.0',
54+
'gcp.function.context.time': '2024-01-01T00:00:00Z',
55+
}),
56+
);
57+
});
58+
59+
test('maps legacy CloudFunctions fields with snake_case attribute names', () => {
60+
mockGetScopeData.mockReturnValue({
61+
contexts: {
62+
'gcp.function.context': {
63+
eventId: 'evt-456',
64+
timestamp: '2024-01-01T00:00:00Z',
65+
eventType: 'providers/cloud.pubsub/eventTypes/topic.publish',
66+
resource: 'projects/my-project/topics/my-topic',
67+
},
68+
},
69+
});
70+
71+
const integration = gcpContextIntegration();
72+
const span = makeSpanJSON();
73+
integration.processSegmentSpan!(span, {} as any);
74+
75+
expect(span.attributes).toEqual(
76+
expect.objectContaining({
77+
'gcp.function.context.event_id': 'evt-456',
78+
'gcp.function.context.timestamp': '2024-01-01T00:00:00Z',
79+
'gcp.function.context.event_type': 'providers/cloud.pubsub/eventTypes/topic.publish',
80+
'gcp.function.context.resource': 'projects/my-project/topics/my-topic',
81+
}),
82+
);
83+
});
84+
85+
test('skips non-string values', () => {
86+
mockGetScopeData.mockReturnValue({
87+
contexts: {
88+
'gcp.function.context': {
89+
type: 'some.event',
90+
resource: { service: 'pubsub', name: 'my-topic' },
91+
data: { payload: 'secret' },
92+
},
93+
},
94+
});
95+
96+
const integration = gcpContextIntegration();
97+
const span = makeSpanJSON();
98+
integration.processSegmentSpan!(span, {} as any);
99+
100+
expect(span.attributes).toEqual(
101+
expect.objectContaining({
102+
'gcp.function.context.type': 'some.event',
103+
}),
104+
);
105+
expect(span.attributes).not.toHaveProperty('gcp.function.context.resource');
106+
expect(span.attributes).not.toHaveProperty('gcp.function.context.data');
107+
});
108+
109+
test('does nothing when no gcp context is set', () => {
110+
mockGetScopeData.mockReturnValue({ contexts: {} });
111+
112+
const integration = gcpContextIntegration();
113+
const span = makeSpanJSON();
114+
integration.processSegmentSpan!(span, {} as any);
115+
116+
expect(span.attributes).toEqual({});
117+
});
118+
});

0 commit comments

Comments
 (0)