Skip to content

feat(instrumentation-aws-sdk): add bedrock extension to apply gen ai conventions #2700

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
24,687 changes: 6,607 additions & 18,080 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@
"@opentelemetry/semantic-conventions": "^1.27.0"
},
"devDependencies": {
"@aws-sdk/client-dynamodb": "3.85.0",
"@aws-sdk/client-kinesis": "3.85.0",
"@aws-sdk/client-lambda": "3.85.0",
"@aws-sdk/client-s3": "3.85.0",
"@aws-sdk/client-sns": "3.85.0",
"@aws-sdk/client-sqs": "3.85.0",
"@aws-sdk/types": "3.78.0",
"@aws-sdk/client-bedrock-runtime": "^3.587.0",
"@aws-sdk/client-dynamodb": "^3.85.0",
"@aws-sdk/client-kinesis": "^3.85.0",
"@aws-sdk/client-lambda": "^3.85.0",
"@aws-sdk/client-s3": "^3.85.0",
"@aws-sdk/client-sns": "^3.85.0",
"@aws-sdk/client-sqs": "^3.85.0",
"@aws-sdk/types": "^3.78.0",
"@opentelemetry/api": "^1.3.0",
"@opentelemetry/contrib-test-utils": "^0.45.0",
"@opentelemetry/sdk-trace-base": "^1.8.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,9 @@
const serviceName =
clientConfig?.serviceId ??
removeSuffixFromStringIfExists(
awsExecutionContext.clientName,
// Use 'AWS' as a fallback serviceName to match type definition.
// In practice, `clientName` should always be set.
awsExecutionContext.clientName || 'AWS',

Check warning on line 451 in plugins/node/opentelemetry-instrumentation-aws-sdk/src/aws-sdk.ts

View check run for this annotation

Codecov / codecov/patch

plugins/node/opentelemetry-instrumentation-aws-sdk/src/aws-sdk.ts#L451

Added line #L451 was not covered by tests
'Client'
);
const commandName =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright The OpenTelemetry Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Gen AI conventions
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you also help copy over comments for the copied ATTR_* attribute keys from the semantic-conventions?

Example from a resource-detector package

Copy link
Contributor

Choose a reason for hiding this comment

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

I have a commit that re-generates the src/semconv.ts using a coming script for this (from #2669), if you are okay with me pushing to your branch, @anuraaga.
It also updates the semconv dep to ^1.29.0 because you are using semconv attributes defined in that version (GEN_AI_SYSTEM_VALUE_AWS_BEDROCK) -- though technically because a copy (in src/semconv.ts) is being used, there isn't really the runtime dep.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @trentm - feel free to push anything you need to this branch


export const ATTR_GEN_AI_SYSTEM = 'gen_ai.system';
export const ATTR_GEN_AI_OPERATION_NAME = 'gen_ai.operation.name';
export const ATTR_GEN_AI_REQUEST_MODEL = 'gen_ai.request.model';
export const ATTR_GEN_AI_REQUEST_MAX_TOKENS = 'gen_ai.request.max_tokens';
export const ATTR_GEN_AI_REQUEST_TEMPERATURE = 'gen_ai.request.temperature';
export const ATTR_GEN_AI_REQUEST_TOP_P = 'gen_ai.request.top_p';
export const ATTR_GEN_AI_REQUEST_STOP_SEQUENCES =
'gen_ai.request.stop_sequences';
export const ATTR_GEN_AI_USAGE_INPUT_TOKENS = 'gen_ai.usage.input_tokens';
export const ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = 'gen_ai.usage.output_tokens';
export const ATTR_GEN_AI_RESPONSE_FINISH_REASONS =
'gen_ai.response.finish_reasons';

export const GEN_AI_SYSTEM_VALUE_AWS_BEDROCK = 'aws.bedrock';
export const GEN_AI_OPERATION_NAME_VALUE_CHAT = 'chat';
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
NormalizedRequest,
NormalizedResponse,
} from '../types';
import { BedrockRuntimeServiceExtension } from './bedrock-runtime';
import { DynamodbServiceExtension } from './dynamodb';
import { SnsServiceExtension } from './sns';
import { LambdaServiceExtension } from './lambda';
Expand All @@ -37,6 +38,7 @@ export class ServicesExtensions implements ServiceExtension {
this.services.set('Lambda', new LambdaServiceExtension());
this.services.set('S3', new S3ServiceExtension());
this.services.set('Kinesis', new KinesisServiceExtension());
this.services.set('BedrockRuntime', new BedrockRuntimeServiceExtension());
}

requestPreSpanHook(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright The OpenTelemetry Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Attributes, DiagLogger, Span, Tracer } from '@opentelemetry/api';
import { RequestMetadata, ServiceExtension } from './ServiceExtension';
import {
ATTR_GEN_AI_SYSTEM,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_REQUEST_MODEL,
ATTR_GEN_AI_REQUEST_MAX_TOKENS,
ATTR_GEN_AI_REQUEST_TEMPERATURE,
ATTR_GEN_AI_REQUEST_TOP_P,
ATTR_GEN_AI_REQUEST_STOP_SEQUENCES,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_GEN_AI_RESPONSE_FINISH_REASONS,
GEN_AI_OPERATION_NAME_VALUE_CHAT,
GEN_AI_SYSTEM_VALUE_AWS_BEDROCK,
} from '../semconv';
import {
AwsSdkInstrumentationConfig,
NormalizedRequest,
NormalizedResponse,
} from '../types';

export class BedrockRuntimeServiceExtension implements ServiceExtension {
requestPreSpanHook(
request: NormalizedRequest,
config: AwsSdkInstrumentationConfig,
diag: DiagLogger
): RequestMetadata {
let spanName: string | undefined;
const spanAttributes: Attributes = {
[ATTR_GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_AWS_BEDROCK,
};

switch (request.commandName) {
case 'Converse':
spanAttributes[ATTR_GEN_AI_OPERATION_NAME] =
GEN_AI_OPERATION_NAME_VALUE_CHAT;
spanName = GEN_AI_OPERATION_NAME_VALUE_CHAT;
break;
}

const modelId = request.commandInput.modelId;
if (modelId) {
spanAttributes[ATTR_GEN_AI_REQUEST_MODEL] = modelId;
if (spanName) {
spanName += ` ${modelId}`;
}
}

const inferenceConfig = request.commandInput.inferenceConfig;
if (inferenceConfig) {
const { maxTokens, temperature, topP, stopSequences } = inferenceConfig;
if (maxTokens !== undefined) {
spanAttributes[ATTR_GEN_AI_REQUEST_MAX_TOKENS] = maxTokens;
}
if (temperature !== undefined) {
spanAttributes[ATTR_GEN_AI_REQUEST_TEMPERATURE] = temperature;
}
if (topP !== undefined) {
spanAttributes[ATTR_GEN_AI_REQUEST_TOP_P] = topP;
}
if (stopSequences !== undefined) {
spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = stopSequences;
}
}

return {
spanName,
isIncoming: false,
spanAttributes,
};
}

responseHook(
response: NormalizedResponse,
span: Span,
tracer: Tracer,
config: AwsSdkInstrumentationConfig
) {
if (!span.isRecording()) {
return;

Check warning on line 96 in plugins/node/opentelemetry-instrumentation-aws-sdk/src/services/bedrock-runtime.ts

View check run for this annotation

Codecov / codecov/patch

plugins/node/opentelemetry-instrumentation-aws-sdk/src/services/bedrock-runtime.ts#L96

Added line #L96 was not covered by tests
}

const { stopReason, usage } = response.data;
const { inputTokens, outputTokens } = usage;
if (inputTokens !== undefined) {
span.setAttribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, inputTokens);
}
if (outputTokens !== undefined) {
span.setAttribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, outputTokens);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

In case usage is undefined:

Suggested change
const { inputTokens, outputTokens } = usage;
if (inputTokens !== undefined) {
span.setAttribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, inputTokens);
}
if (outputTokens !== undefined) {
span.setAttribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, outputTokens);
}
if (usage) {
const { inputTokens, outputTokens } = usage;
if (inputTokens !== undefined) {
span.setAttribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, inputTokens);
}
if (outputTokens !== undefined) {
span.setAttribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, outputTokens);
}
}


if (stopReason !== undefined) {
span.setAttribute(ATTR_GEN_AI_RESPONSE_FINISH_REASONS, [stopReason]);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright The OpenTelemetry Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* These tests verify telemetry created against actual API responses
* which can be difficult to mock for LLMs. The responses are recorded
* automatically using nock's nock-back feature. Responses are recorded
* to the mock-responses directory with the name of the test - by default
* if a response is available for the current test it is used, and
* otherwise a real request is made and the response is recorded.
* To re-record all responses, set the NOCK_BACK_MODE environment variable
* to 'update' - when recording responses, valid AWS credentials for
* accessing bedrock are also required.
*/

import {
getTestSpans,
registerInstrumentationTesting,
} from '@opentelemetry/contrib-test-utils';
import { AwsInstrumentation } from '../src';
registerInstrumentationTesting(new AwsInstrumentation());

import {
BedrockRuntimeClient,
ConverseCommand,
ConversationRole,
} from '@aws-sdk/client-bedrock-runtime';
import * as path from 'path';
import { Definition, back as nockBack } from 'nock';

import { ReadableSpan } from '@opentelemetry/sdk-trace-base';
import {
ATTR_GEN_AI_SYSTEM,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_REQUEST_MODEL,
ATTR_GEN_AI_REQUEST_MAX_TOKENS,
ATTR_GEN_AI_REQUEST_TEMPERATURE,
ATTR_GEN_AI_REQUEST_TOP_P,
ATTR_GEN_AI_REQUEST_STOP_SEQUENCES,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_GEN_AI_RESPONSE_FINISH_REASONS,
GEN_AI_SYSTEM_VALUE_AWS_BEDROCK,
GEN_AI_OPERATION_NAME_VALUE_CHAT,
} from '../src/semconv';
import { expect } from 'expect';

const region = 'us-east-1';

// Remove any data from recorded responses that could have sensitive data
// and that we don't need for testing.
const sanitizeRecordings = (scopes: Definition[]) => {
for (const scope of scopes) {
// Type definition seems to be incorrect of headers.
const headers: string[] = (scope as any).rawHeaders;
for (let i = 0; i < headers.length; i += 2) {
if (headers[i].toLowerCase() === 'set-cookie') {
headers.splice(i, 2);
}
}
}
return scopes;
};

describe('Bedrock', () => {
const client = new BedrockRuntimeClient({ region });

nockBack.fixtures = path.join(__dirname, 'mock-responses');
if (!process.env.NOCK_BACK_MODE) {
nockBack.setMode('record');
}

let nockDone: () => void;
beforeEach(async function () {
const filename = `${this.currentTest
?.fullTitle()
.toLowerCase()
.replace(/\s/g, '-')}.json`;
const { nockDone: nd } = await nockBack(filename, {
afterRecord: sanitizeRecordings,
});
nockDone = nd;
});

afterEach(async function () {
nockDone();
});

describe('Converse', () => {
it('adds genai conventions', async () => {
const modelId = 'amazon.titan-text-lite-v1';
const messages = [
{
role: ConversationRole.USER,
content: [{ text: 'Say this is a test' }],
},
];
const inferenceConfig = {
maxTokens: 10,
temperature: 0.8,
topP: 1,
stopSequences: ['|'],
};

const command = new ConverseCommand({
modelId,
messages,
inferenceConfig,
});
const response = await client.send(command);
expect(response.output?.message?.content?.[0].text).toBe(
"Hi. I'm not sure what"
);

const testSpans: ReadableSpan[] = getTestSpans();
const converseSpans: ReadableSpan[] = testSpans.filter(
(s: ReadableSpan) => {
return s.name === 'chat amazon.titan-text-lite-v1';
}
);
expect(converseSpans.length).toBe(1);
expect(converseSpans[0].attributes).toMatchObject({
[ATTR_GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_AWS_BEDROCK,
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_CHAT,
[ATTR_GEN_AI_REQUEST_MODEL]: modelId,
[ATTR_GEN_AI_REQUEST_MAX_TOKENS]: 10,
[ATTR_GEN_AI_REQUEST_TEMPERATURE]: 0.8,
[ATTR_GEN_AI_REQUEST_TOP_P]: 1,
[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES]: ['|'],
[ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 8,
[ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 10,
[ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['max_tokens'],
});
});
});
});
Loading
Loading