diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 802de7ccb6..3324c301db 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -128,6 +128,9 @@ components: - sharadraju packages/instrumentation-runtime-node: - d4nyll + packages/instrumentation-openai: + - trentm + - seemk packages/instrumentation-pg: - maryliag packages/instrumentation-pino: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f22b1b37b9..64982fd4ec 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -50,6 +50,7 @@ "packages/instrumentation-mysql2": "0.50.0", "packages/instrumentation-nestjs-core": "0.49.0", "packages/instrumentation-net": "0.47.0", + "packages/instrumentation-openai": "0.0.1", "packages/instrumentation-oracledb": "0.29.0", "packages/instrumentation-pg": "0.56.0", "packages/instrumentation-pino": "0.50.0", diff --git a/package-lock.json b/package-lock.json index ce6cac21c8..78752d0c0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7318,6 +7318,24 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.38.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.38.7.tgz", + "integrity": "sha512-Jkb27iSn7JPdkqlTqKfhncFfnEZsIJVYxsFbUSWEkxdIPdsyngrhoDBk0/BGD2FQcRH99vlRrkHpNTyKqI+0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -8540,6 +8558,31 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -9031,6 +9074,10 @@ "resolved": "packages/instrumentation-net", "link": true }, + "node_modules/@opentelemetry/instrumentation-openai": { + "resolved": "packages/instrumentation-openai", + "link": true + }, "node_modules/@opentelemetry/instrumentation-oracledb": { "resolved": "packages/instrumentation-oracledb", "link": true @@ -11090,6 +11137,24 @@ "generic-pool": "*" } }, + "node_modules/@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + } + }, + "node_modules/@types/glob/node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-assert": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", @@ -11267,6 +11332,17 @@ "undici-types": "~7.8.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -11551,6 +11627,109 @@ } } }, + "node_modules/@typescript-eslint/experimental-utils": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.8.1.tgz", + "integrity": "sha512-fbodVnjIDU4JpeXWRDsG5IfIjYBxEvs8EBO8W1+YVdtrc2B9ppfof5sZhVEDOtgTfFHnYQJDI8+qdqLYO4ceww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.8.1", + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/typescript-estree": "5.8.1", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.8.1.tgz", + "integrity": "sha512-DGxJkNyYruFH3NIZc3PwrzwOQAg7vvgsHsHCILOLvUpupgkwDZdNq/cXU3BjF4LNrCsVg0qxEyWasys5AiJ85Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/visitor-keys": "5.8.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/types": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.8.1.tgz", + "integrity": "sha512-L/FlWCCgnjKOLefdok90/pqInkomLnAcF9UAzNr+DSqMC3IffzumHTQTrINXhP1gVp9zlHiYYjvozVZDPleLcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.8.1.tgz", + "integrity": "sha512-26lQ8l8tTbG7ri7xEcCFT9ijU5Fk+sx/KRRyyzCv7MQ+rZZlqiDPtMKWLC8P7o+dtCnby4c+OlxuX1tp8WfafQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/visitor-keys": "5.8.1", + "debug": "^4.3.2", + "globby": "^11.0.4", + "is-glob": "^4.0.3", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.8.1.tgz", + "integrity": "sha512-SWgiWIwocK6NralrJarPZlWdr0hZnj5GXHIgfdm8hNkyKvpeQuFyLP6YjSIe9kf3YBIfU6OHSZLYkQ+smZwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.8.1", + "eslint-visitor-keys": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/parser": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", @@ -17819,6 +17998,27 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/formidable": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", @@ -19692,6 +19892,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -24436,6 +24643,27 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -25432,6 +25660,13 @@ "node": ">=0.10.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -29321,6 +29556,13 @@ "bare-events": "^2.2.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", @@ -31353,6 +31595,16 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -34144,6 +34396,282 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, + "packages/instrumentation-openai": { + "name": "@opentelemetry/instrumentation-openai", + "version": "0.0.1", + "dependencies": { + "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "devDependencies": { + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/contrib-test-utils": "^0.49.0", + "@opentelemetry/sdk-logs": "^0.203.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0", + "@types/glob": "^8.1.0", + "@types/node": "18.18.14", + "@typescript-eslint/eslint-plugin": "5.8.1", + "@typescript-eslint/parser": "5.8.1", + "cross-env": "7.0.3", + "expect": "29.2.0", + "nock": "^14.0.0", + "nyc": "17.1.0", + "openai": "4.104.0", + "rimraf": "5.0.10", + "safe-stable-stringify": "^2.4.1", + "sinon": "15.2.0", + "test-all-versions": "6.1.0", + "typescript": "5.0.4" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "packages/instrumentation-openai/node_modules/@types/node": { + "version": "18.18.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.14.tgz", + "integrity": "sha512-iSOeNeXYNYNLLOMDSVPvIFojclvMZ/HDY2dU17kUlcsOsSQETbWIslJbYLZgA+ox8g2XQwSHKTkght1a5X26lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "packages/instrumentation-openai/node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.8.1.tgz", + "integrity": "sha512-wTZ5oEKrKj/8/366qTM366zqhIKAp6NCMweoRONtfuC07OAU9nVI2GZZdqQ1qD30WAAtcPdkH+npDwtRFdp4Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/experimental-utils": "5.8.1", + "@typescript-eslint/scope-manager": "5.8.1", + "debug": "^4.3.2", + "functional-red-black-tree": "^1.0.1", + "ignore": "^5.1.8", + "regexpp": "^3.2.0", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/instrumentation-openai/node_modules/@typescript-eslint/parser": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.8.1.tgz", + "integrity": "sha512-K1giKHAjHuyB421SoXMXFHHVI4NdNY603uKw92++D3qyxSeYvC10CBJ/GE5Thpo4WTUvu1mmJI2/FFkz38F2Gw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "5.8.1", + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/typescript-estree": "5.8.1", + "debug": "^4.3.2" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/instrumentation-openai/node_modules/@typescript-eslint/scope-manager": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.8.1.tgz", + "integrity": "sha512-DGxJkNyYruFH3NIZc3PwrzwOQAg7vvgsHsHCILOLvUpupgkwDZdNq/cXU3BjF4LNrCsVg0qxEyWasys5AiJ85Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/visitor-keys": "5.8.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/instrumentation-openai/node_modules/@typescript-eslint/types": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.8.1.tgz", + "integrity": "sha512-L/FlWCCgnjKOLefdok90/pqInkomLnAcF9UAzNr+DSqMC3IffzumHTQTrINXhP1gVp9zlHiYYjvozVZDPleLcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/instrumentation-openai/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.8.1.tgz", + "integrity": "sha512-26lQ8l8tTbG7ri7xEcCFT9ijU5Fk+sx/KRRyyzCv7MQ+rZZlqiDPtMKWLC8P7o+dtCnby4c+OlxuX1tp8WfafQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/visitor-keys": "5.8.1", + "debug": "^4.3.2", + "globby": "^11.0.4", + "is-glob": "^4.0.3", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/instrumentation-openai/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.8.1.tgz", + "integrity": "sha512-SWgiWIwocK6NralrJarPZlWdr0hZnj5GXHIgfdm8hNkyKvpeQuFyLP6YjSIe9kf3YBIfU6OHSZLYkQ+smZwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.8.1", + "eslint-visitor-keys": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/instrumentation-openai/node_modules/nock": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.5.tgz", + "integrity": "sha512-R49fALR9caB6vxuSWUIaK2eBYeTloZQUFBZ4rHO+TbhMGQHtwnhdqKLYki+o+8qMgLvoBYWrp/2KzGPhxL4S6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.38.7", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, + "packages/instrumentation-openai/node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "packages/instrumentation-openai/node_modules/typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=12.20" + } + }, + "packages/instrumentation-openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "packages/instrumentation-openai/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/instrumentation-oracledb": { "name": "@opentelemetry/instrumentation-oracledb", "version": "0.29.0", @@ -41734,6 +42262,20 @@ "sparse-bitfield": "^3.0.3" } }, + "@mswjs/interceptors": { + "version": "0.38.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.38.7.tgz", + "integrity": "sha512-Jkb27iSn7JPdkqlTqKfhncFfnEZsIJVYxsFbUSWEkxdIPdsyngrhoDBk0/BGD2FQcRH99vlRrkHpNTyKqI+0/w==", + "dev": true, + "requires": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + } + }, "@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -42594,6 +43136,28 @@ "@octokit/openapi-types": "^24.2.0" } }, + "@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "requires": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -44407,6 +44971,160 @@ } } }, + "@opentelemetry/instrumentation-openai": { + "version": "file:packages/instrumentation-openai", + "requires": { + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/contrib-test-utils": "^0.49.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/sdk-logs": "^0.203.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.36.0", + "@types/glob": "^8.1.0", + "@types/node": "18.18.14", + "@typescript-eslint/eslint-plugin": "5.8.1", + "@typescript-eslint/parser": "5.8.1", + "cross-env": "7.0.3", + "expect": "29.2.0", + "nock": "^14.0.0", + "nyc": "17.1.0", + "openai": "4.104.0", + "rimraf": "5.0.10", + "safe-stable-stringify": "^2.4.1", + "sinon": "15.2.0", + "test-all-versions": "6.1.0", + "typescript": "5.0.4" + }, + "dependencies": { + "@types/node": { + "version": "18.18.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.14.tgz", + "integrity": "sha512-iSOeNeXYNYNLLOMDSVPvIFojclvMZ/HDY2dU17kUlcsOsSQETbWIslJbYLZgA+ox8g2XQwSHKTkght1a5X26lQ==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } + }, + "@typescript-eslint/eslint-plugin": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.8.1.tgz", + "integrity": "sha512-wTZ5oEKrKj/8/366qTM366zqhIKAp6NCMweoRONtfuC07OAU9nVI2GZZdqQ1qD30WAAtcPdkH+npDwtRFdp4Rw==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "5.8.1", + "@typescript-eslint/scope-manager": "5.8.1", + "debug": "^4.3.2", + "functional-red-black-tree": "^1.0.1", + "ignore": "^5.1.8", + "regexpp": "^3.2.0", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/parser": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.8.1.tgz", + "integrity": "sha512-K1giKHAjHuyB421SoXMXFHHVI4NdNY603uKw92++D3qyxSeYvC10CBJ/GE5Thpo4WTUvu1mmJI2/FFkz38F2Gw==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.8.1", + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/typescript-estree": "5.8.1", + "debug": "^4.3.2" + } + }, + "@typescript-eslint/scope-manager": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.8.1.tgz", + "integrity": "sha512-DGxJkNyYruFH3NIZc3PwrzwOQAg7vvgsHsHCILOLvUpupgkwDZdNq/cXU3BjF4LNrCsVg0qxEyWasys5AiJ85Q==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/visitor-keys": "5.8.1" + } + }, + "@typescript-eslint/types": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.8.1.tgz", + "integrity": "sha512-L/FlWCCgnjKOLefdok90/pqInkomLnAcF9UAzNr+DSqMC3IffzumHTQTrINXhP1gVp9zlHiYYjvozVZDPleLcA==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.8.1.tgz", + "integrity": "sha512-26lQ8l8tTbG7ri7xEcCFT9ijU5Fk+sx/KRRyyzCv7MQ+rZZlqiDPtMKWLC8P7o+dtCnby4c+OlxuX1tp8WfafQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/visitor-keys": "5.8.1", + "debug": "^4.3.2", + "globby": "^11.0.4", + "is-glob": "^4.0.3", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.8.1.tgz", + "integrity": "sha512-SWgiWIwocK6NralrJarPZlWdr0hZnj5GXHIgfdm8hNkyKvpeQuFyLP6YjSIe9kf3YBIfU6OHSZLYkQ+smZwtNg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.8.1", + "eslint-visitor-keys": "^3.0.0" + } + }, + "nock": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.5.tgz", + "integrity": "sha512-R49fALR9caB6vxuSWUIaK2eBYeTloZQUFBZ4rHO+TbhMGQHtwnhdqKLYki+o+8qMgLvoBYWrp/2KzGPhxL4S6w==", + "dev": true, + "requires": { + "@mswjs/interceptors": "^0.38.7", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + } + }, + "openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "dev": true, + "requires": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "optional": true, + "peer": true, + "requires": {} + } + } + }, "@opentelemetry/instrumentation-oracledb": { "version": "file:packages/instrumentation-oracledb", "requires": { @@ -47461,6 +48179,24 @@ "generic-pool": "*" } }, + "@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "requires": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + }, + "dependencies": { + "@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true + } + } + }, "@types/http-assert": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", @@ -47636,6 +48372,16 @@ "undici-types": "~7.8.0" } }, + "@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -47902,6 +48648,63 @@ "tsutils": "^3.21.0" } }, + "@typescript-eslint/experimental-utils": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.8.1.tgz", + "integrity": "sha512-fbodVnjIDU4JpeXWRDsG5IfIjYBxEvs8EBO8W1+YVdtrc2B9ppfof5sZhVEDOtgTfFHnYQJDI8+qdqLYO4ceww==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.8.1", + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/typescript-estree": "5.8.1", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.8.1.tgz", + "integrity": "sha512-DGxJkNyYruFH3NIZc3PwrzwOQAg7vvgsHsHCILOLvUpupgkwDZdNq/cXU3BjF4LNrCsVg0qxEyWasys5AiJ85Q==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/visitor-keys": "5.8.1" + } + }, + "@typescript-eslint/types": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.8.1.tgz", + "integrity": "sha512-L/FlWCCgnjKOLefdok90/pqInkomLnAcF9UAzNr+DSqMC3IffzumHTQTrINXhP1gVp9zlHiYYjvozVZDPleLcA==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.8.1.tgz", + "integrity": "sha512-26lQ8l8tTbG7ri7xEcCFT9ijU5Fk+sx/KRRyyzCv7MQ+rZZlqiDPtMKWLC8P7o+dtCnby4c+OlxuX1tp8WfafQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.8.1", + "@typescript-eslint/visitor-keys": "5.8.1", + "debug": "^4.3.2", + "globby": "^11.0.4", + "is-glob": "^4.0.3", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.8.1.tgz", + "integrity": "sha512-SWgiWIwocK6NralrJarPZlWdr0hZnj5GXHIgfdm8hNkyKvpeQuFyLP6YjSIe9kf3YBIfU6OHSZLYkQ+smZwtNg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.8.1", + "eslint-visitor-keys": "^3.0.0" + } + } + } + }, "@typescript-eslint/parser": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", @@ -52672,6 +53475,22 @@ "mime-types": "^2.1.12" } }, + "form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "dev": true + }, + "formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dev": true, + "requires": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + } + }, "formidable": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", @@ -54048,6 +54867,12 @@ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true }, + "is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -57767,6 +58592,12 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "dev": true + }, "node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -58531,6 +59362,12 @@ "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "dev": true }, + "outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, "own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -61492,6 +62329,12 @@ "text-decoder": "^1.1.0" } }, + "strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", @@ -62935,6 +63778,12 @@ "defaults": "^1.0.3" } }, + "web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "dev": true + }, "webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/packages/instrumentation-openai/.eslintignore b/packages/instrumentation-openai/.eslintignore new file mode 100644 index 0000000000..378eac25d3 --- /dev/null +++ b/packages/instrumentation-openai/.eslintignore @@ -0,0 +1 @@ +build diff --git a/packages/instrumentation-openai/.eslintrc.js b/packages/instrumentation-openai/.eslintrc.js new file mode 100644 index 0000000000..f726f3becb --- /dev/null +++ b/packages/instrumentation-openai/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "env": { + "mocha": true, + "node": true + }, + ...require('../../eslint.config.js') +} diff --git a/packages/instrumentation-openai/.tav.yml b/packages/instrumentation-openai/.tav.yml new file mode 100644 index 0000000000..ac264a8790 --- /dev/null +++ b/packages/instrumentation-openai/.tav.yml @@ -0,0 +1,5 @@ +openai: + versions: + include: '>=4.19.0 <6' + mode: max-5 + commands: npm test diff --git a/packages/instrumentation-openai/LICENSE b/packages/instrumentation-openai/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/packages/instrumentation-openai/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. diff --git a/packages/instrumentation-openai/README.md b/packages/instrumentation-openai/README.md new file mode 100644 index 0000000000..4f030da907 --- /dev/null +++ b/packages/instrumentation-openai/README.md @@ -0,0 +1,56 @@ +# OpenTelemetry OpenAI module Instrumentation for Node.js + +[![NPM Published Version][npm-img]][npm-url] +[![Apache License][license-image]][license-image] + +This module provides automatic instrumentation for the [`openai`](https://www.npmjs.com/package/openai) module, which may be loaded using the [`@opentelemetry/sdk-trace-node`](https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-sdk-trace-node) package. + +Compatible with OpenTelemetry JS API and SDK `1.0+`. + +## Installation + +```bash +npm install --save @opentelemetry/instrumentation-openai +``` + +## Supported Versions + +- openai `>=4.19.0 <6` + +## Usage + +```js +const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node'); +const { OpenAIInstrumentation } = require('@opentelemetry/instrumentation-openai'); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); + +const provider = new NodeTracerProvider(); +provider.register(); + +registerInstrumentations({ + instrumentations: [ + new OpenAIInstrumentation(), + // other instrumentations + ], +}); +``` + +## Semantic Conventions + +This package implements Semantic Convention [Version 1.36.0](https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/README.md). + +## Useful links + +- For more information on OpenTelemetry, visit: +- For more about OpenTelemetry JavaScript: +- For help or feedback on this project, join us in [GitHub Discussions][discussions-url] + +## License + +Apache 2.0 - See [LICENSE][license-url] for more information. + +[discussions-url]: https://github.com/open-telemetry/opentelemetry-js/discussions +[license-url]: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/LICENSE +[license-image]: https://img.shields.io/badge/license-Apache_2.0-green.svg?style=flat +[npm-url]: https://www.npmjs.com/package/@opentelemetry/instrumentation-openai +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Finstrumentation-openai.svg diff --git a/packages/instrumentation-openai/package.json b/packages/instrumentation-openai/package.json new file mode 100644 index 0000000000..05a42df71c --- /dev/null +++ b/packages/instrumentation-openai/package.json @@ -0,0 +1,76 @@ +{ + "name": "@opentelemetry/instrumentation-openai", + "version": "0.0.1", + "description": "OpenTelemetry instrumentation for the `openai` OpenAI client library", + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/open-telemetry/opentelemetry-js-contrib.git", + "directory": "packages/instrumentation-openai" + }, + "scripts": { + "clean": "rimraf build/*", + "setup:dev": "nx run-many -t compile -p @opentelemetry/instrumentation-openai", + "compile": "tsc -p .", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "lint:readme": "node ../../scripts/lint-readme.js", + "prepublishOnly": "npm run compile", + "tdd": "npm run test -- --watch-extensions ts --watch", + "test": "nyc mocha --require '@opentelemetry/contrib-test-utils' 'test/**/*.test.ts'", + "test-all-versions": "tav", + "version:update": "node ../../scripts/version-update.js", + "watch": "tsc -w" + }, + "keywords": [ + "instrumentation", + "opentelemetry", + "observability", + "openai", + "llm", + "genai" + ], + "author": "OpenTelemetry Authors", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.js.map", + "build/src/**/*.d.ts" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "devDependencies": { + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/contrib-test-utils": "^0.49.0", + "@opentelemetry/sdk-logs": "^0.203.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0", + "@types/glob": "^8.1.0", + "@types/node": "18.18.14", + "@typescript-eslint/eslint-plugin": "5.8.1", + "@typescript-eslint/parser": "5.8.1", + "cross-env": "7.0.3", + "expect": "29.2.0", + "nock": "^14.0.0", + "nyc": "17.1.0", + "openai": "4.104.0", + "rimraf": "5.0.10", + "safe-stable-stringify": "^2.4.1", + "sinon": "15.2.0", + "test-all-versions": "6.1.0", + "typescript": "5.0.4" + }, + "dependencies": { + "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-openai#readme" +} diff --git a/packages/instrumentation-openai/src/index.ts b/packages/instrumentation-openai/src/index.ts new file mode 100644 index 0000000000..da127f7b58 --- /dev/null +++ b/packages/instrumentation-openai/src/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ +export { OpenAIInstrumentation } from './instrumentation'; +export type { OpenAIInstrumentationConfig } from './types'; diff --git a/packages/instrumentation-openai/src/instrumentation.ts b/packages/instrumentation-openai/src/instrumentation.ts new file mode 100644 index 0000000000..22277c711f --- /dev/null +++ b/packages/instrumentation-openai/src/instrumentation.ts @@ -0,0 +1,842 @@ +/* + * 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. + */ + +// avoids a dependency on @opentelemetry/core for hrTime utilities +import { performance } from 'perf_hooks'; + +import { context, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { Attributes, Context, Histogram, Span } from '@opentelemetry/api'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, +} from '@opentelemetry/instrumentation'; +import { SeverityNumber } from '@opentelemetry/api-logs'; +import type { + ChatCompletion, + ChatCompletionMessageToolCall, + ChatCompletionContentPart, + ChatCompletionContentPartRefusal, + ChatCompletionContentPartText, + ChatCompletionCreateParams, + ChatCompletionMessageParam, + ChatCompletionChunk, + Completions as ChatCompletions, +} from 'openai/resources/chat/completions'; +import type { APIPromise } from 'openai/core'; +import type { + CreateEmbeddingResponse, + Embeddings, + EmbeddingCreateParams, +} from 'openai/resources/embeddings'; +import type { Stream } from 'openai/streaming'; + +import { + ATTR_EVENT_NAME, + ATTR_GEN_AI_OPERATION_NAME, + ATTR_GEN_AI_REQUEST_ENCODING_FORMATS, + ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY, + ATTR_GEN_AI_REQUEST_MAX_TOKENS, + ATTR_GEN_AI_REQUEST_MODEL, + ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY, + ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, + ATTR_GEN_AI_REQUEST_TEMPERATURE, + ATTR_GEN_AI_REQUEST_TOP_P, + ATTR_GEN_AI_RESPONSE_FINISH_REASONS, + ATTR_GEN_AI_RESPONSE_ID, + ATTR_GEN_AI_RESPONSE_MODEL, + ATTR_GEN_AI_SYSTEM, + ATTR_GEN_AI_TOKEN_TYPE, + ATTR_GEN_AI_USAGE_INPUT_TOKENS, + ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, + METRIC_GEN_AI_CLIENT_OPERATION_DURATION, + METRIC_GEN_AI_CLIENT_TOKEN_USAGE, +} from './semconv'; +/** @knipignore */ +import { PACKAGE_NAME, PACKAGE_VERSION } from './version'; +import { getEnvBool, getAttrsFromBaseURL } from './utils'; +import { OpenAIInstrumentationConfig } from './types'; +import { + GenAIMessage, + GenAIChoiceEventBody, + GenAISystemMessageEventBody, + GenAIUserMessageEventBody, + GenAIAssistantMessageEventBody, + GenAIToolMessageEventBody, +} from './internal-types'; + +// The JS semconv package doesn't yet emit constants for event names. +// TODO: otel-js issue for semconv pkg not including event names +const EVENT_GEN_AI_SYSTEM_MESSAGE = 'gen_ai.system.message'; +const EVENT_GEN_AI_USER_MESSAGE = 'gen_ai.user.message'; +const EVENT_GEN_AI_ASSISTANT_MESSAGE = 'gen_ai.assistant.message'; +const EVENT_GEN_AI_TOOL_MESSAGE = 'gen_ai.tool.message'; +const EVENT_GEN_AI_CHOICE = 'gen_ai.choice'; + +export class OpenAIInstrumentation extends InstrumentationBase { + private _genaiClientOperationDuration!: Histogram; + private _genaiClientTokenUsage!: Histogram; + + constructor(config: OpenAIInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + + // Possible environment variable overrides for config. + const cfg = this.getConfig(); + const envCC = getEnvBool( + 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT', + this._diag + ); + if (envCC !== undefined) { + cfg.captureMessageContent = envCC; + } + } + + // Override InstrumentationAbtract.setConfig so we can normalize config. + override setConfig(config: OpenAIInstrumentationConfig = {}) { + const { captureMessageContent, ...validConfig } = config; + (validConfig as OpenAIInstrumentationConfig).captureMessageContent = + !!captureMessageContent; + super.setConfig(validConfig); + } + + protected init() { + return [ + new InstrumentationNodeModuleDefinition( + 'openai', + ['>=4.19.0 <6'], + modExports => { + this._wrap( + modExports.OpenAI.Chat.Completions.prototype, + 'create', + this._getPatchedChatCompletionsCreate() + ); + this._wrap( + modExports.OpenAI.Embeddings.prototype, + 'create', + this._getPatchedEmbeddingsCreate() + ); + + return modExports; + }, + modExports => { + this._unwrap(modExports.OpenAI.Chat.Completions.prototype, 'create'); + this._unwrap(modExports.OpenAI.Embeddings.prototype, 'create'); + } + ), + ]; + } + + // This is a 'protected' method on class `InstrumentationAbstract`. + override _updateMetricInstruments() { + this._genaiClientOperationDuration = this.meter.createHistogram( + METRIC_GEN_AI_CLIENT_OPERATION_DURATION, + { + description: 'GenAI operation duration', + unit: 's', + advice: { + explicitBucketBoundaries: [ + 0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, + 20.48, 40.96, 81.92, + ], + }, + } + ); + this._genaiClientTokenUsage = this.meter.createHistogram( + METRIC_GEN_AI_CLIENT_TOKEN_USAGE, + { + description: 'Measures number of input and output tokens used', + unit: '{token}', + advice: { + explicitBucketBoundaries: [ + 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, + 4194304, 16777216, 67108864, + ], + }, + } + ); + } + + _getPatchedChatCompletionsCreate() { + const self = this; + return (original: ChatCompletions['create']) => { + // https://platform.openai.com/docs/api-reference/chat/create + return function patchedCreate( + this: ChatCompletions, + ...args: Parameters + ) { + if (!self.isEnabled) { + return original.apply(this, args); + } + + self._diag.debug('OpenAI.Chat.Completions.create args: %O', args); + const params = args[0]; + const config = self.getConfig(); + const startNow = performance.now(); + + let startInfo; + try { + startInfo = self._startChatCompletionsSpan( + params, + config, + this?._client?.baseURL + ); + } catch (err) { + self._diag.error('unexpected error starting span:', err); + return original.apply(this, args); + } + const { span, ctx, commonAttrs } = startInfo; + + const apiPromise = context.with(ctx, () => original.apply(this, args)); + + // Streaming. + if (isStreamPromise(params, apiPromise)) { + // When streaming, `apiPromise` resolves to `Stream`, + // an async iterable (i.e. has a `Symbol.asyncIterator` method). We + // want to wrap that iteration to gather telemetry. Instead of wrapping + // `Symbol.asyncIterator`, which would be nice, we wrap the `iterator` + // method because it is used internally by `Stream#tee()`. + return apiPromise.then(stream => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + self._wrap(stream as any, 'iterator', origIterator => { + return () => { + return self._onChatCompletionsStreamIterator( + origIterator(), + span, + startNow, + config, + commonAttrs, + ctx + ); + }; + }); + return stream; + }); + } + + // Non-streaming. + apiPromise + .then(result => { + self._onChatCompletionsCreateResult( + span, + startNow, + commonAttrs, + result as ChatCompletion, + config, + ctx + ); + }) + .catch( + self._createAPIPromiseRejectionHandler(startNow, span, commonAttrs) + ); + + return apiPromise; + }; + }; + } + + /** + * Start a span for this chat-completion API call. This also emits log events + * as appropriate for the request params. + */ + _startChatCompletionsSpan( + params: ChatCompletionCreateParams, + config: OpenAIInstrumentationConfig, + baseURL: string | undefined + ) { + // Attributes common to span, metrics, log events. + const commonAttrs: Attributes = { + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: params.model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + }; + Object.assign(commonAttrs, getAttrsFromBaseURL(baseURL, this._diag)); + + // Span attributes. + const attrs: Attributes = { + ...commonAttrs, + }; + if (params.frequency_penalty != null) { + attrs[ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY] = params.frequency_penalty; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((params as any).max_completion_tokens != null) { + attrs[ATTR_GEN_AI_REQUEST_MAX_TOKENS] = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (params as any).max_completion_tokens; + } else if (params.max_tokens != null) { + // `max_tokens` is deprecated in favour of `max_completion_tokens`. + attrs[ATTR_GEN_AI_REQUEST_MAX_TOKENS] = params.max_tokens; + } + if (params.presence_penalty != null) { + attrs[ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY] = params.presence_penalty; + } + if (params.stop != null) { + if (Array.isArray(params.stop)) { + attrs[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = params.stop; + } else { + attrs[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = [params.stop]; + } + } + if (params.temperature != null) { + attrs[ATTR_GEN_AI_REQUEST_TEMPERATURE] = params.temperature; + } + if (params.top_p != null) { + attrs[ATTR_GEN_AI_REQUEST_TOP_P] = params.top_p; + } + + const span: Span = this.tracer.startSpan( + `${attrs[ATTR_GEN_AI_OPERATION_NAME]} ${attrs[ATTR_GEN_AI_REQUEST_MODEL]}`, + { + kind: SpanKind.CLIENT, + attributes: attrs, + } + ); + const ctx: Context = trace.setSpan(context.active(), span); + + // Capture prompts as log events. + const timestamp = Date.now(); + params.messages.forEach((msg: ChatCompletionMessageParam) => { + switch (msg.role) { + case 'system': { + const body: GenAISystemMessageEventBody = {}; + if (config.captureMessageContent) { + if (Array.isArray(msg.content)) { + body.content = msg.content.map(p => p.text).join(''); + } else { + body.content = msg.content; + } + } + this.logger.emit({ + timestamp, + context: ctx, + severityNumber: SeverityNumber.INFO, + attributes: { + [ATTR_EVENT_NAME]: EVENT_GEN_AI_SYSTEM_MESSAGE, + [ATTR_GEN_AI_SYSTEM]: 'openai', + }, + body, + }); + break; + } + case 'user': { + const body: GenAIUserMessageEventBody = {}; + if (config.captureMessageContent) { + if (Array.isArray(msg.content)) { + body.content = msg.content + .filter(isTextContent) + .map(p => p.text) + .join(''); + } else { + body.content = msg.content; + } + } + this.logger.emit({ + timestamp, + context: ctx, + severityNumber: SeverityNumber.INFO, + attributes: { + [ATTR_EVENT_NAME]: EVENT_GEN_AI_USER_MESSAGE, + [ATTR_GEN_AI_SYSTEM]: 'openai', + }, + body, + }); + break; + } + case 'assistant': { + const body: GenAIAssistantMessageEventBody = {}; + if (config.captureMessageContent) { + if (msg.content) { + if (Array.isArray(msg.content)) { + body.content = msg.content + .filter(isTextContent) + .map(p => p.text) + .join(''); + } else { + body.content = msg.content; + } + } + body.tool_calls = msg.tool_calls?.map(tc => { + return { + id: tc.id, + type: tc.type, + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }; + }); + } else { + body.tool_calls = msg.tool_calls?.map(tc => { + return { + id: tc.id, + type: tc.type, + function: { name: tc.function.name }, + }; + }); + } + this.logger.emit({ + timestamp, + context: ctx, + severityNumber: SeverityNumber.INFO, + attributes: { + [ATTR_EVENT_NAME]: EVENT_GEN_AI_ASSISTANT_MESSAGE, + [ATTR_GEN_AI_SYSTEM]: 'openai', + }, + body, + }); + break; + } + case 'tool': { + const body: GenAIToolMessageEventBody = { + id: msg.tool_call_id, + }; + if (config.captureMessageContent) { + if (Array.isArray(msg.content)) { + body.content = msg.content.map(p => p.text).join(''); + } else { + body.content = msg.content; + } + } + this.logger.emit({ + timestamp, + context: ctx, + severityNumber: SeverityNumber.INFO, + attributes: { + [ATTR_EVENT_NAME]: EVENT_GEN_AI_TOOL_MESSAGE, + [ATTR_GEN_AI_SYSTEM]: 'openai', + }, + body, + }); + break; + } + default: + this._diag.debug( + `unknown message role in OpenAI.Chat.Completions.create: ${msg.role}` + ); + } + }); + + return { span, ctx, commonAttrs }; + } + + /** + * This wraps an instance of a `openai/streaming.Stream.iterator()`, an + * async iterator. It should yield the chunks unchanged, and gather telemetry + * data from those chunks, then end the span. + */ + async *_onChatCompletionsStreamIterator( + streamIter: AsyncIterator, + span: Span, + startNow: number, + config: OpenAIInstrumentationConfig, + commonAttrs: Attributes, + ctx: Context + ) { + let id; + let model; + const finishReasons: string[] = []; + const choices = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for await (const chunk of streamIter as any) { + yield chunk; + + // Gather telemetry from this chunk. + this._diag.debug( + 'OpenAI.Chat.Completions.create stream chunk: %O', + chunk + ); + const idx = chunk.choices[0]?.index ?? 0; + if (!choices[idx]) { + choices[idx] = {} as { + content: string; + toolCalls: ChatCompletionMessageToolCall[]; + }; + } + if (config.captureMessageContent) { + const contentPart = chunk.choices[0]?.delta?.content; + if (contentPart) { + if (!choices[idx].content) { + choices[idx].content = ''; + } + choices[idx].content += contentPart; + } + } + // Assume delta.tool_calls, if exists, is an array of length 1. + const toolCallPart = chunk.choices[0]?.delta?.tool_calls?.[0]; + if (toolCallPart) { + if (!choices[idx].toolCalls) { + choices[idx].toolCalls = []; + } + const toolCalls = choices[idx].toolCalls; + if (toolCallPart.id) { + // First chunk in a tool call. + toolCalls.push({ + id: toolCallPart.id, + type: toolCallPart.type, + function: { + name: toolCallPart.function?.name, + arguments: toolCallPart.function?.arguments ?? '', + }, + }); + } else if (toolCalls.length > 0) { + // A tool call chunk with more of the `function.arguments`. + toolCalls[toolCalls.length - 1].function.arguments += + toolCallPart.function?.arguments ?? ''; + } + } + if (!id && chunk.id) { + id = chunk.id; + span.setAttribute(ATTR_GEN_AI_RESPONSE_ID, id); + } + if (!model && chunk.model) { + model = chunk.model; + span.setAttribute(ATTR_GEN_AI_RESPONSE_MODEL, model); + } + if (!finishReasons[idx]) { + const finishReason = chunk.choices[0]?.finish_reason; + if (finishReason) { + finishReasons[idx] = finishReason; + } + } + if (chunk.usage) { + // A final usage chunk if `stream_options.include_usage: true`. + span.setAttribute( + ATTR_GEN_AI_USAGE_INPUT_TOKENS, + chunk.usage.prompt_tokens + ); + span.setAttribute( + ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, + chunk.usage.completion_tokens + ); + this._genaiClientTokenUsage.record(chunk.usage.prompt_tokens, { + ...commonAttrs, + [ATTR_GEN_AI_RESPONSE_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + }); + this._genaiClientTokenUsage.record(chunk.usage.completion_tokens, { + ...commonAttrs, + [ATTR_GEN_AI_RESPONSE_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + }); + } + } + span.setAttribute( + ATTR_GEN_AI_RESPONSE_FINISH_REASONS, + finishReasons.filter(f => f !== undefined) + ); + + // Capture choices as log events. + for (const [idx, choice] of choices.entries()) { + if (!choice) { + continue; + } + const message: Partial = {}; + if ( + config.captureMessageContent && + choice.content && + choice.content.length > 0 + ) { + message.content = choice.content; + } + if (choice.toolCalls && choice.toolCalls.length > 0) { + message.tool_calls = choice.toolCalls; + if (!config.captureMessageContent) { + message.tool_calls.forEach(tc => { + delete tc.function?.arguments; + }); + } + } + this.logger.emit({ + timestamp: Date.now(), + context: ctx, + severityNumber: SeverityNumber.INFO, + attributes: { + [ATTR_EVENT_NAME]: EVENT_GEN_AI_CHOICE, + [ATTR_GEN_AI_SYSTEM]: 'openai', + }, + body: { + finish_reason: finishReasons[idx], + index: idx, + message, + } as GenAIChoiceEventBody, + }); + } + + this._genaiClientOperationDuration.record( + (performance.now() - startNow) / 1000, + { + ...commonAttrs, + [ATTR_GEN_AI_RESPONSE_MODEL]: model, + } + ); + + span.end(); + } + + _onChatCompletionsCreateResult( + span: Span, + startNow: number, + commonAttrs: Attributes, + result: ChatCompletion, + config: OpenAIInstrumentationConfig, + ctx: Context + ) { + this._diag.debug('OpenAI.Chat.Completions.create result: %O', result); + try { + span.setAttribute( + ATTR_GEN_AI_RESPONSE_FINISH_REASONS, + result.choices.map(c => c.finish_reason) + ); + span.setAttribute(ATTR_GEN_AI_RESPONSE_ID, result.id); + span.setAttribute(ATTR_GEN_AI_RESPONSE_MODEL, result.model); + if (result.usage) { + span.setAttribute( + ATTR_GEN_AI_USAGE_INPUT_TOKENS, + result.usage.prompt_tokens + ); + span.setAttribute( + ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, + result.usage.completion_tokens + ); + } + + // Capture choices as log events. + result.choices.forEach(choice => { + const message: Partial = {}; + if (config.captureMessageContent) { + // TODO: telemetry diff with streaming case: content=null, no 'role: assistant', 'tool calls (enableCaptureContent=true)' test case + if (choice.message.content) { + message.content = choice.message.content; + } + if (choice.message.tool_calls) { + message.tool_calls = choice.message.tool_calls; + } + } else { + if (choice.message.tool_calls) { + message.tool_calls = choice.message.tool_calls.map(tc => { + return { + id: tc.id, + type: tc.type, + function: { name: tc.function.name }, + }; + }); + } + } + this.logger.emit({ + timestamp: Date.now(), + context: ctx, + severityNumber: SeverityNumber.INFO, + attributes: { + [ATTR_EVENT_NAME]: EVENT_GEN_AI_CHOICE, + [ATTR_GEN_AI_SYSTEM]: 'openai', + }, + body: { + finish_reason: choice.finish_reason, + index: choice.index, + message, + } as GenAIChoiceEventBody, + }); + }); + + this._genaiClientOperationDuration.record( + (performance.now() - startNow) / 1000, + { + ...commonAttrs, + [ATTR_GEN_AI_RESPONSE_MODEL]: result.model, + } + ); + + if (result.usage) { + this._genaiClientTokenUsage.record(result.usage.prompt_tokens, { + ...commonAttrs, + [ATTR_GEN_AI_RESPONSE_MODEL]: result.model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + }); + + this._genaiClientTokenUsage.record(result.usage.completion_tokens, { + ...commonAttrs, + [ATTR_GEN_AI_RESPONSE_MODEL]: result.model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + }); + } + } catch (err) { + this._diag.error( + 'unexpected error getting telemetry from chat result:', + err + ); + } + span.end(); + } + + _createAPIPromiseRejectionHandler( + startNow: number, + span: Span, + commonAttrs: Attributes + ) { + return (err: Error) => { + this._diag.debug('OpenAI APIPromise rejection: %O', err); + + // https://github.com/openai/openai-node/blob/master/src/error.ts + // The most reliable low cardinality string for errors seems to be + // the class name. See also: + // https://platform.openai.com/docs/guides/error-codes + const errorType = err?.constructor?.name; + + this._genaiClientOperationDuration.record( + (performance.now() - startNow) / 1000, + { + ...commonAttrs, + 'error.type': errorType, + } + ); + + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + + span.setAttribute('error.type', errorType); + span.end(); + }; + } + + _getPatchedEmbeddingsCreate() { + const self = this; + return (original: Embeddings['create']) => { + // https://platform.openai.com/docs/api-reference/embeddings/create + return function patchedCreate( + this: Embeddings, + ...args: Parameters + ) { + if (!self.isEnabled) { + return original.apply(this, args); + } + + self._diag.debug('OpenAI.Chat.Embeddings.create args: %O', args); + const params = args[0]; + const startNow = performance.now(); + + let startInfo; + try { + startInfo = self._startEmbeddingsSpan(params, this?._client?.baseURL); + } catch (err) { + self._diag.error('unexpected error starting span:', err); + return original.apply(this, args); + } + const { span, ctx, commonAttrs } = startInfo; + + const apiPromise = context.with(ctx, () => original.apply(this, args)); + + apiPromise + .then(result => { + self._onEmbeddingsCreateResult(span, startNow, commonAttrs, result); + }) + .catch( + self._createAPIPromiseRejectionHandler(startNow, span, commonAttrs) + ); + + return apiPromise; + }; + }; + } + + /** + * Start a span for this chat-completion API call. This also emits log events + * as appropriate for the request params. + */ + _startEmbeddingsSpan( + params: EmbeddingCreateParams, + baseURL: string | undefined + ) { + // Attributes common to span, metrics, log events. + const commonAttrs: Attributes = { + [ATTR_GEN_AI_OPERATION_NAME]: 'embeddings', + [ATTR_GEN_AI_REQUEST_MODEL]: params.model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + }; + Object.assign(commonAttrs, getAttrsFromBaseURL(baseURL, this._diag)); + + // Span attributes. + const attrs: Attributes = { + ...commonAttrs, + }; + if (params.encoding_format != null) { + attrs[ATTR_GEN_AI_REQUEST_ENCODING_FORMATS] = [params.encoding_format]; + } + + const span = this.tracer.startSpan( + `${attrs[ATTR_GEN_AI_OPERATION_NAME]} ${attrs[ATTR_GEN_AI_REQUEST_MODEL]}`, + { + kind: SpanKind.CLIENT, + attributes: attrs, + } + ); + const ctx = trace.setSpan(context.active(), span); + + return { span, ctx, commonAttrs }; + } + + _onEmbeddingsCreateResult( + span: Span, + startNow: number, + commonAttrs: Attributes, + result: CreateEmbeddingResponse + ) { + this._diag.debug('OpenAI.Embeddings.create result: %O', result); + try { + span.setAttribute(ATTR_GEN_AI_RESPONSE_MODEL, result.model); + + this._genaiClientOperationDuration.record( + (performance.now() - startNow) / 1000, + { + ...commonAttrs, + [ATTR_GEN_AI_RESPONSE_MODEL]: result.model, + } + ); + + span.setAttribute( + ATTR_GEN_AI_USAGE_INPUT_TOKENS, + result.usage.prompt_tokens + ); + this._genaiClientTokenUsage.record(result.usage.prompt_tokens, { + ...commonAttrs, + [ATTR_GEN_AI_RESPONSE_MODEL]: result.model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + }); + } catch (err) { + this._diag.error( + 'unexpected error getting telemetry from embeddings result:', + err + ); + } + span.end(); + } +} + +function isTextContent( + value: ChatCompletionContentPart | ChatCompletionContentPartRefusal +): value is ChatCompletionContentPartText { + return value.type === 'text'; +} + +function isStreamPromise( + params: ChatCompletionCreateParams | undefined, + value: APIPromise | ChatCompletion> +): value is APIPromise> { + if (params && params.stream) { + return true; + } + return false; +} diff --git a/packages/instrumentation-openai/src/internal-types.ts b/packages/instrumentation-openai/src/internal-types.ts new file mode 100644 index 0000000000..98476f1d20 --- /dev/null +++ b/packages/instrumentation-openai/src/internal-types.ts @@ -0,0 +1,61 @@ +/* + * 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 { AnyValue } from '@opentelemetry/api-logs'; + +export type GenAIFunction = { + name: string; + arguments?: AnyValue; +}; + +export type GenAIToolCall = { + id: string; + type: string; + function: GenAIFunction; +}; + +export type GenAIMessage = { + role?: string; + content?: AnyValue; + tool_calls?: GenAIToolCall[]; +}; + +export type GenAIChoiceEventBody = { + finish_reason: string; + index: number; + message: GenAIMessage; +}; + +export type GenAISystemMessageEventBody = { + role?: string; + content?: AnyValue; +}; + +export type GenAIUserMessageEventBody = { + role?: string; + content?: AnyValue; +}; + +export type GenAIAssistantMessageEventBody = { + role?: string; + content?: AnyValue; + tool_calls?: GenAIToolCall[]; +}; + +export type GenAIToolMessageEventBody = { + role?: string; + content?: AnyValue; + id: string; +}; diff --git a/packages/instrumentation-openai/src/semconv.ts b/packages/instrumentation-openai/src/semconv.ts new file mode 100644 index 0000000000..36591a0c74 --- /dev/null +++ b/packages/instrumentation-openai/src/semconv.ts @@ -0,0 +1,219 @@ +/* + * 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. + */ + +/* + * This file contains a copy of unstable semantic convention definitions + * used by this package. + * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv + */ + +/** + * Identifies the class / type of event. + * + * @example browser.mouse.click + * @example device.app.lifecycle + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + * + * @deprecated Replaced by EventName top-level field on the LogRecord. + */ +export const ATTR_EVENT_NAME = 'event.name' as const; + +/** + * The name of the operation being performed. + * + * @note If one of the predefined values applies, but specific system uses a different name it's **RECOMMENDED** to document it in the semantic conventions for specific GenAI system and use system-specific name in the instrumentation. If a different name is not documented, instrumentation libraries **SHOULD** use applicable predefined value. + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_OPERATION_NAME = 'gen_ai.operation.name' as const; + +/** + * The encoding formats requested in an embeddings operation, if specified. + * + * @example ["base64"] + * @example ["float", "binary"] + * + * @note In some GenAI systems the encoding formats are called embedding types. Also, some GenAI systems only accept a single format per request. + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_REQUEST_ENCODING_FORMATS = + 'gen_ai.request.encoding_formats' as const; + +/** + * The frequency penalty setting for the GenAI request. + * + * @example 0.1 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY = + 'gen_ai.request.frequency_penalty' as const; + +/** + * The maximum number of tokens the model generates for a request. + * + * @example 100 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_REQUEST_MAX_TOKENS = + 'gen_ai.request.max_tokens' as const; + +/** + * The name of the GenAI model a request is being made to. + * + * @example "gpt-4" + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_REQUEST_MODEL = 'gen_ai.request.model' as const; + +/** + * The presence penalty setting for the GenAI request. + * + * @example 0.1 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY = + 'gen_ai.request.presence_penalty' as const; + +/** + * List of sequences that the model will use to stop generating further tokens. + * + * @example ["forest", "lived"] + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_REQUEST_STOP_SEQUENCES = + 'gen_ai.request.stop_sequences' as const; + +/** + * The temperature setting for the GenAI request. + * + * @example 0.0 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_REQUEST_TEMPERATURE = + 'gen_ai.request.temperature' as const; + +/** + * The top_p sampling setting for the GenAI request. + * + * @example 1.0 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_REQUEST_TOP_P = 'gen_ai.request.top_p' as const; + +/** + * Array of reasons the model stopped generating tokens, corresponding to each generation received. + * + * @example ["stop"] + * @example ["stop", "length"] + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_RESPONSE_FINISH_REASONS = + 'gen_ai.response.finish_reasons' as const; + +/** + * The unique identifier for the completion. + * + * @example chatcmpl-123 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_RESPONSE_ID = 'gen_ai.response.id' as const; + +/** + * The name of the model that generated the response. + * + * @example gpt-4-0613 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_RESPONSE_MODEL = 'gen_ai.response.model' as const; + +/** + * The Generative AI product as identified by the client or server instrumentation. + * + * @example "openai" + * + * @note The `gen_ai.system` describes a family of GenAI models with specific model identified + * by `gen_ai.request.model` and `gen_ai.response.model` attributes. + * + * The actual GenAI product may differ from the one identified by the client. + * Multiple systems, including Azure OpenAI and Gemini, are accessible by OpenAI client + * libraries. In such cases, the `gen_ai.system` is set to `openai` based on the + * instrumentation's best knowledge, instead of the actual system. The `server.address` + * attribute may help identify the actual system in use for `openai`. + * + * For custom model, a custom friendly name **SHOULD** be used. + * If none of these options apply, the `gen_ai.system` **SHOULD** be set to `_OTHER`. + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_SYSTEM = 'gen_ai.system' as const; + +/** + * The type of token being counted. + * + * @example input + * @example output + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_TOKEN_TYPE = 'gen_ai.token.type' as const; + +/** + * The number of tokens used in the GenAI input (prompt). + * + * @example 100 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_USAGE_INPUT_TOKENS = + 'gen_ai.usage.input_tokens' as const; + +/** + * The number of tokens used in the GenAI response (completion). + * + * @example 180 + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = + 'gen_ai.usage.output_tokens' as const; + +/** + * GenAI operation duration + * + * @experimental This metric is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const METRIC_GEN_AI_CLIENT_OPERATION_DURATION = + 'gen_ai.client.operation.duration' as const; + +/** + * Measures number of input and output tokens used + * + * @experimental This metric is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const METRIC_GEN_AI_CLIENT_TOKEN_USAGE = + 'gen_ai.client.token.usage' as const; diff --git a/packages/instrumentation-openai/src/types.ts b/packages/instrumentation-openai/src/types.ts new file mode 100644 index 0000000000..7a8a11242e --- /dev/null +++ b/packages/instrumentation-openai/src/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { InstrumentationConfig } from '@opentelemetry/instrumentation'; + +export interface OpenAIInstrumentationConfig extends InstrumentationConfig { + /** + * Set to true to enable capture of content data, such as prompt and + * completion content, tool call function arguments, etc. By default, this is + * `false` to avoid possible exposure of sensitive data. This can also be set + * via the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` + * environment variable. + */ + captureMessageContent?: boolean; +} diff --git a/packages/instrumentation-openai/src/utils.ts b/packages/instrumentation-openai/src/utils.ts new file mode 100644 index 0000000000..039ac7cec7 --- /dev/null +++ b/packages/instrumentation-openai/src/utils.ts @@ -0,0 +1,101 @@ +/* + * 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 { diag } from '@opentelemetry/api'; +import type { Attributes, DiagLogger } from '@opentelemetry/api'; +import { + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; + +/** + * Read a boolean from an environment variable. + * + * https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#boolean + * + * @param {string} name + * @returns {boolean | undefined} + * - Returns `undefined` if the envvar is not set on `process.env` or is + * the empty string. This indicates that no explicit value was given, + * which may be a useful distinction from an explicit `false` for callers. + * - Returns `true` iff the envvar value is the string "true" (case-insensitive). + * - Returns `false`, iff the envvar value is the string "false" (case-insensitive). + * - Otherwise, it `diag.warn()`s about the invalid value and returns + * `undefined` as a (falsey) fallback. + * @throws if the envvar value is set and is not a string + */ +export function getEnvBool( + name: string, + diag_: DiagLogger = diag +): boolean | undefined { + const val = process.env[name]; + if (val === undefined || val === '') { + return undefined; + } else if (typeof val !== 'string') { + throw new Error( + `invalid type for environment variable: ${typeof val} (${name}=${val})` + ); + } else { + const valLower = val.toLowerCase(); + if (valLower === 'true') { + return true; + } else if (valLower === 'false') { + return false; + } else { + diag_.warn( + `invalid boolean value for environment variable: ${name}=${val}; ignoring` + ); + return undefined; + } + } +} + +type PortFromProtocol = { [key: string]: number }; +const SERVER_PORT_FROM_URL_PROTOCOL: PortFromProtocol = { + 'https:': 443, + 'http:': 80, +}; + +/** + * Return span/metric attributes from the given OpenAI client baseURL. + */ +export function getAttrsFromBaseURL( + baseURL: string | undefined, + diag_: DiagLogger = diag +): Attributes | undefined { + if (!baseURL) { + return; + } + + // TODO: would be nice to LRU cache this, but probably not significant perf + let u; + try { + u = new URL(baseURL); + } catch (ex) { + // Note: We should never get to this point as openai should crash prior to this. + // Even if it did, instrumentation will still work except lacking these attributes. + diag_.debug( + `could not determine server.{address,port} from baseURL: ${ex}` + ); + return; + } + + return { + [ATTR_SERVER_ADDRESS]: u.hostname, + [ATTR_SERVER_PORT]: u.port + ? Number(u.port) + : SERVER_PORT_FROM_URL_PROTOCOL[u.protocol], + }; +} diff --git a/packages/instrumentation-openai/test/load-instrumentation.ts b/packages/instrumentation-openai/test/load-instrumentation.ts new file mode 100644 index 0000000000..d7f673b6db --- /dev/null +++ b/packages/instrumentation-openai/test/load-instrumentation.ts @@ -0,0 +1,65 @@ +/* + * 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. + */ + +/** + * Because all tests in this folder are run in the same process, if instantiating + * instrumentation within tests with different configurations such as metrics support, + * it can be difficult to ensure the correct instrumentation is applied during the + * specific test. We instead instantiate a single instrumentation instance here to + * use within all tests. + */ +import { registerInstrumentationTesting } from '@opentelemetry/contrib-test-utils'; +import { + InMemoryLogRecordExporter, + LoggerProvider, + SimpleLogRecordProcessor, +} from '@opentelemetry/sdk-logs'; +import { + AggregationTemporality, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from '@opentelemetry/sdk-metrics'; +import { OpenAIInstrumentation } from '../src'; + +export const instrumentation = new OpenAIInstrumentation(); +export const metricExporter = new InMemoryMetricExporter( + AggregationTemporality.DELTA +); +export const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: metricExporter, + }), + ], +}); +instrumentation.setMeterProvider(meterProvider); + +export const logsExporter = new InMemoryLogRecordExporter(); +export const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(logsExporter)], +}); +instrumentation.setLoggerProvider(loggerProvider); + +registerInstrumentationTesting(instrumentation); +instrumentation.disable(); + +export const contentCaptureInstrumentation = new OpenAIInstrumentation({ + captureMessageContent: true, +}); +contentCaptureInstrumentation.setMeterProvider(meterProvider); +contentCaptureInstrumentation.setLoggerProvider(loggerProvider); +contentCaptureInstrumentation.disable(); diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-genai-conventions-with-system-message.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-genai-conventions-with-system-message.json new file mode 100644 index 0000000000..cf5f947a5e --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-genai-conventions-with-system-message.json @@ -0,0 +1,49 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are an assistant which just answers every query with tomato" + }, + { + "role": "user", + "content": "Say something" + } + ] + }, + "status": 200, + "response": [ + "1f8b08000000000000038c92414f1b311085effb2bac39675158821272041554950b11e2d00aad8c3dd9186c8f65cf564428ffbdb213b20ba5522f3ef89b377e6f3c6f951060342c05a88d64e582ad2ffbcbb3edeab5a187fb9fb7dfaf573f1edcb7bb8bedd562d55fddc0242be8e91915bfab4e14b960910df93d56112563ee7a3a3f6fe6cde2fc625680238d36cbbac0f58c6a67bca99b6933aba7f3fa7471506fc8284cb014bf2a2184782b67f6e935bec2524c27ef370e53921dc2f258240444b2f906644a26b1f40c93012af28cbe58bf2727994ec634e2ba4f323bf4bdb52320bd27963961f1f57820bba3134b5d88f4943e49616dbc499b36a24ce4f3ab892940a1bb4a88c792b8ff1002422417b8657ac1f25c33dbb78361ce033c3b30269676a4994fbe68d66a64696c1a0d0c94541bd4837298aeecb5a111a84691fff6f255ef7d6ce3bbff693f00a53030ea3644d4467dcc3b9445cc4bf8afb2e3888b6148187f1b852d1b8cf91b34ae656ff7ab01699b185dbb36bec318a229fb91bfb1da557f000000ffff03004946b69d22030000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "9607310f4e8625e4-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 05:03:14 GMT", + "openai-processing-ms": "239", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "241", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999977", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_12b64a999af2cbe909602adc344dba39" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-genai-conventions.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-genai-conventions.json new file mode 100644 index 0000000000..b2c0b294d8 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-genai-conventions.json @@ -0,0 +1,45 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ] + }, + "status": 200, + "response": [ + "1f8b0800000000000003000000ffff8c92cb6edb301045f7fa0a62d65660cbafc0bb268b3e16eda628120481c09063795c8aa4c9919122f0bf07a41d4b4e53a01b2e78e60eef1dce4b210490869500b591ac5a6fca9b58cd6ebf7f997efdf9bc5fcc77dffce7edaf3bdadeef17bbbb0a4649e19eb6a8f84d75a55ceb0d32397bc42aa0644c5d27cb79554d96d3f92283d6693449d6782e67ae6cc952598dab59395e9693eb937ae34861849578288410e2259fc9a7d5f80c2b311ebdddb418a36c1056e722212038936e40c64891a56518f55039cb68b3f54f6ca46552e2874269af865501d75d94c9a9ed8c190069ad639992667f8f2772383b32aef1c13dc577525893a5b8a903cae86c7a3db2f390e9a110e23127ef2ec2800faef55cb3fb8df9b9aa3ab6837ede3d9c9e183b9666a0998f3e68566b6449260e06074aaa0dea5ed94f59769adc001483c87f7bf9a8f73136d9e67fdaf74029f48cbaf60135a9cbbc7d59c0b48cff2a3b8f381b8688614f0a6b260ce91b34ae65678e2b02f14f646ceb35d906830f94f7247d6371285e010000ffff0300cc59e0e82a030000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "95d66fac3c52ec0c-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Fri, 11 Jul 2025 07:02:37 GMT", + "openai-processing-ms": "354", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "365", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999982", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_5b9a48ab38990a73e0f90e6d6b7b6efd" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-records-multiple-choices.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-records-multiple-choices.json new file mode 100644 index 0000000000..8371de1b40 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-adds-records-multiple-choices.json @@ -0,0 +1,46 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "n": 2 + }, + "status": 200, + "response": [ + "1f8b0800000000000003dc53c16e133110bdef57583e67ab649b2a21b78613955a0e201042d5cab527bb436d8fb1c788aaeabf236fd2eca62d12672e3ecc9b377ecff3fc580921d1c88d90ba57ac5db0f5366fafd639c17bf7b3bbf65fbe7d7de8c2e7fb6bf7e1266fe772561874f703343fb3ce34b9608191fc1ed611144399ba585d34abe6ddc5f972001c19b085d605ae97543bf45837f36659cf57f5627d60f7841a92dc88ef9510423c0e67d1e90dfc961b319f3d571ca4a43a909b63931032922d15a952c2c4cab39c8da026cfe007e99f28732f2ed92acfa8c5470dca9f4d7b23ec725245afcfd64e00e53db12a7e0795b707e4e9a8cb521722dda51754b9438fa96f23a844be68484c415613f22bb38bffd26c25c4edb0ec7c624986482e70cb740fc3754db31f27c7888de0fa8031b1b263f9fc908fd361ad015668d3e4f9a456ba073332c760a96c9026c07445afb5bc357b6f1b7df72fe347406b080ca60d110cea53bf635b84f2fffed6767ce241b04c107fa1869611625983819dca761f14991e12836b77e83b8821e29096b2c6eaa9fa030000ffff0300ad34d3271d040000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96074804aaf0d758-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 05:18:55 GMT", + "openai-processing-ms": "438", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "444", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999981", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_fbc58335d597b112a3387a4e3fb2225e" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-handles-connection-errors-without-crashing.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-handles-connection-errors-without-crashing.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-handles-connection-errors-without-crashing.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-all-the-client-options.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-all-the-client-options.json new file mode 100644 index 0000000000..adac57b9c0 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-all-the-client-options.json @@ -0,0 +1,55 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "frequency_penalty": 0, + "max_tokens": 100, + "presence_penalty": 0, + "temperature": 1, + "top_p": 1, + "stop": "foo", + "seed": 100, + "response_format": { + "type": "text" + } + }, + "status": 200, + "response": [ + "1f8b0800000000000003000000ffff8c924f4fe33010c5eff914d69c1bd486565d7a2c7be0040758045aa1c8b5a789771d8fb1272b10ea7747764b13fe497bf1c1bf79e3f7c6f352080146c34a806a25abcedb72ddaf2f7eaaf3eee2f16cb33e9f56b7cbf6f6f2897edd991b7f0f93a4a0cd1f54fca63a51d4798b6cc8edb10a281953d7d972512dabb3f96c9141471a6d92359ecb39959d71a6aca6d5bc9c2ecbd98f83ba25a330c24afc2e8410e2259fc9a7d3f8042b319dbcdd7418a36c1056c7222120904d3720633491a563980c50916374d9fa35f5dc6270e24aa17427e3aa80db3ecae4d4f5d68e80748e58a6a4d9dfc381ec8e8e2c353ed0267e90c2d63813db3aa08ce4d2eb91c943a6bb4288879cbc7f17067ca0ce73cdf417f37355b56f07c3bc07787a604c2ced48b3987cd1acd6c8d2d8381a1c28a95ad4837298b2ecb5a1112846913f7bf9aaf73eb671cdffb41f8052e81975ed036aa3dee71dca02a665fcaeec38e26c1822867f4661cd0643fa068d5bd9dbfd8a407c8e8c5dbd35aec1e083c97b92beb1d815af000000ffff03002e1e6b642a030000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "9607451c6e66d79b-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 05:16:56 GMT", + "openai-processing-ms": "206", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "208", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999982", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_5dd31d8f9aa5eb13a6e7903923492d90" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-multiple-choices.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-multiple-choices.json new file mode 100644 index 0000000000..2c83acc2c0 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-multiple-choices.json @@ -0,0 +1,46 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "n": 2 + }, + "status": 200, + "response": [ + "1f8b0800000000000003000000ffffc453c14ee33010bde72b2c9f1b948642d9de800b62b5cb6141202114b9f63419d6f1187b8240887f474e4b131656e280c4c507bf79cfefcd8c9f3221241ab91052378a75eb6d7ed41d5d1e5ffdda2f7ec693fb30bf3b5dfe2eee4ecf4f2ecedb1f07729218b4bc05cdafac1d4dadb7c0486e0deb008a21a94ee77be57cb7d89dcd7aa0250336d16acff98cf2161de66551cef2629e4f37e2ba21d410e5425c674208f1d49fc9a733f02017a298bcdeb410a3aa412eb64542c84036dd48152346568ee56400353906d75b3f64ab1ca316671a94db19570558755125a7aeb3760428e788554adafbbbd920cf5b47966a1f6819ffa1ca153a8c4d15404572e9f5c8e4653622bf8b39fd9a987fa8e30682fbf698991037fd80bb3761a40fd47aae98fe42ff5c59aee5e4b05603b8bfc19858d911e760f28158658015da386a9cd44a376006e6b04caa334823603c9cf75e3ed25ec746577f467e00b406cf602a1fc0a07e9b77280b90fedcffcab62dee0dcb08e11e35548c10d2180cac5467d72b22e3636468ab15ba1a820fd8ef491a63f69cbd000000ffff0300a0db7ac411040000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96075bba7f96e361-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 05:32:24 GMT", + "openai-processing-ms": "345", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "839", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999981", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_647fdbd760685b6f73c5cc23138cfda1" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-tool-calls.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-tool-calls.json new file mode 100644 index 0000000000..5ce77418f1 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-records-tool-calls.json @@ -0,0 +1,172 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant providing weather updates." + }, + { + "role": "user", + "content": "What is the weather in New York City and London?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + } + } + ] + }, + "status": 200, + "response": [ + "1f8b0800000000000003000000ffffcc54c1729b3010bdf3159a3d9b0ec68e7138d6ed346ddc349ee4d04e9d6164b1806a215149d8713cfef70e100376d24e8ee5c0887d7a6fdf2e2bed1d4280c710126019b52c2f84fbbe9c798b9bf4367b4aaeb681bddb7e929bbb6f9fe793d1e4cb350c2a865afd42668fac774ce58540cb956c60a6915aac5487c1851f8c7c7f38ad815cc5282a5a5a5877acdc9c4beefa9e3f76bdc01d4e9fd999e20c0d84e4a7430821fbfa5df994313e4248bcc13192a331344508db4d848056a28a0035861b4ba585410732252dcacaba2c85e801562911312a4497b879f6bd75d72c2a4474fbfdd62fe3af57dee2e3accc778f653616851ced7af91ae95d511b4a4ac9da26f5f0361e9e25230424cd6b6e8a36da22b519ea333a2140755ae6286d651df64b108ad14a70092159c20d6ec90fa5d764c6ed6e090738a11f7a5f87c19beabebf5e5fb26015a49b0fbf678ba7cdd49b2b1624f7ff59dd7325e36afdaf82dbf5436f143426a5a1e2e58c502995ad33d443f2e09cb50d844a0bad56e68c0a0997dc6491466aea72fbd3e61c8dd416a03c196828b4ca0b1b59b5c63ae945d0884277e63a703c7906adb25474f1a1371abc2217c56829af07be3d638cb20ce38eda9d355ac65cf500a757fa4b37af6937e57399be45be0318c3c2621c151a63ce4e2beeb669acaea4bf6d6b9b5c1b06837ac3194696a3ae7e478c092d45735180d9198b79947099a22e346f6f0be7e0fc010000ffff0300a64600b830050000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96078988eb44f669-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 06:03:39 GMT", + "openai-processing-ms": "1191", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "1218", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999970", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_7975a25b0266245986eb0145433f3813" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant providing weather updates." + }, + { + "role": "user", + "content": "What is the weather in New York City and London?" + }, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_PXP2udMH0QECumyxuh4lpn3y", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"New York City\"}" + } + }, + { + "id": "call_TKk9c7b7gvDqCQzv80Loc7fT", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"London\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_PXP2udMH0QECumyxuh4lpn3y", + "content": "25 degrees and sunny" + }, + { + "role": "tool", + "tool_call_id": "call_TKk9c7b7gvDqCQzv80Loc7fT", + "content": "15 degrees and raining" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + } + } + ] + }, + "status": 200, + "response": [ + "1f8b08000000000000038c524b6fd34010befb578cf66c57b1db10c8b105c18187841008a1cadaec4eec69ec59b33b26842affbd5a278d1d68252e7b98efb1f3cdcc7d02a0c8aa2528536b316dd764d7fdcdecf337f9f969dbbf6defda9adfacbe6efe7c78b7e6cbdd6b9546855bdda19147d585716dd7a090e3036c3c6ac1e89a2fe6c5e2b228f25703d03a8b4d94559d64572e6b89292b66c555365b64f9cba3ba766430a825fc480000ee8737f6c9167fab25ccd2c74a8b21e80ad5f2440250de35b1a274081444b3a874048d63411e5aff52236c514b8d1e88e1236ee1bbf31bb821d9010528e660b1f28801345b083df32e856d4d0d46fe7bc7d6710a24919c9f93bd2626ae2ea65f7b5cf741c7f8dc37cd04d0cc4e741cdf10faf688ec4f311b5775deadc25f52b526a650971e75701c2305719d1ad07d02703b8cb33f9b90eabc6b3b29c56d70f82e2fe6073f356e71448b1747509ce866a29ae7e9137ea545d1d484c94294d1a6463b4ac7ede9de929b00c924f5bfdd3ce57d484e5cfd8ffd0818839da02d3b8f96cc79e291e6311ef973b4d39487865540ff8b0c9642e8e3262cae75df1c4e4f855d106ccb357185bef334dc5fdc64b24f1e000000ffff0300ab11441282030000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96078993eaf0f277-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 06:03:40 GMT", + "openai-organization": "elastic-observability", + "openai-processing-ms": "889", + "openai-project": "proj_Pf1eM5R55Z35wBy4rt8PxAGq", + "openai-version": "2020-10-01", + "server": "cloudflare", + "set-cookie": "_cfuvid=1dVbru2CzTfprgmbY2GyPqzvZehSNU6Q0AUMvx.9FYw-1752732220761-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "933", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999958", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_c5854b92f43699c9e6c43a7327a0193d" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-adds-genai-conventions.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-adds-genai-conventions.json new file mode 100644 index 0000000000..1aa721a9ec --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-adds-genai-conventions.json @@ -0,0 +1,45 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ] + }, + "status": 200, + "response": [ + "1f8b0800000000000003000000ffff8c924d6fdb300c86effe1582ce7191b8c9b2e6b68f532fc5b02f0c4361b012637395454da2870d45fefb20258dddad0376d1410f5fea7d293e544a69b27aa7b4e941cc105cfd7a7cf3fdfaedf5c7fb34ace09d8d1d5ddd8495912f9f3e349ff5222bf8ee1b1a79545d181e824321f6476c228260eebada6e9aede5667db92960608b2ecbba20f59aeb813cd5cdb259d7cb6dbd7a7952f74c0693dea9af95524a3d9433fbf4167fea9d5a2e1e6f064c093ad4bb7391523ab2cb371a52a224e0452f2668d80bfa62fd3d8fd2ab57e2c00b19756310fcc5bc36e27e4c90fdfad1b91900ef5920e72d2e6f4fe470f6e5b80b91efd21f52bd274fa96f2342629f3d24e1a00b3d544add96fce393483a441e82b4c2f7589e6b9a633b3d4d7d82eb1313167033cd8bc533cd5a8b02e4d26c7cda80e9d14eca69d6305ae219a86691fff6f25cef636cf2ddffb49f803118046d1b225a324ff34e6511f34afeabec3ce26258278c3fc8602b84317f83c53d8ceeb8283afd4a8243bb27df610c91cab6e46fac0ed56f000000ffff0300e23e590f30030000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "9607d8125a07df60-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 06:57:15 GMT", + "openai-processing-ms": "263", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "273", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999982", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_3d48791f2e8bf2254beddbe3f3dca9e9" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-records-multiple-choices.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-records-multiple-choices.json new file mode 100644 index 0000000000..83b667fe4e --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-records-multiple-choices.json @@ -0,0 +1,46 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "n": 2 + }, + "status": 200, + "response": [ + "1f8b0800000000000003000000ffffc4534d4fe33010bde757583e37a8cd0229bdf1715b2190405a69572832f63499e2782c7b824088ffbe72529a940f89c34a7bf1c16fdef37b33e3974c088946ae84d48d62dd7a9b9f75174bfbbb9b3f9e878d33eafca1bcded8cdedaf939bb39f9772961874bf01cd6fac034dadb7c0486e807500c5905417e55151fe383e2a4f7aa0250336d16acff921e52d3acc8b797198cfcb7cb1dcb21b420d51aec49f4c08215efa33f974069ee44acc676f372dc4a86a90ab5d911032904d3752c588919563391b414d8ec1f5d66fa8e3469cb2558e518b2b0dca1d4c6b03acbba8925fd7593b019473c42ae5ed5dde6d91d79d2f4bb50f741fdf51e51a1dc6a60aa022b9e4213279994dc81fc22efe6158086e88f9ff526642dcf553eef6b2481fa8f55c313d40ff5c510c7272dcad113cde624cacec84b39c7d2256196085364efa26b5d20d9891396e94ea0cd20498cee6a397cfb487d8e8eaefc88f80d6e0194ce50318d4fb79c7b200e9e37d55b66b716f5846088fa8a1628490c66060ad3a3b6c888ccf91a1add6e86a083e60bf26698cd96bf6170000ffff03003b0c3ca016040000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "9607f3ff4c2f7376-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 07:16:19 GMT", + "openai-processing-ms": "459", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "462", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999981", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_c8606b5a0df9cbb0e34366d284418548" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-records-tool-calls.json b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-records-tool-calls.json new file mode 100644 index 0000000000..3da0ca1443 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-chat-completions-with-content-capture-records-tool-calls.json @@ -0,0 +1,172 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant providing weather updates." + }, + { + "role": "user", + "content": "What is the weather in New York City and London?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + } + } + ] + }, + "status": 200, + "response": [ + "1f8b0800000000000003cc545d6f9b30147de75758f7394cf926e16ddd47272d5b966dea5a2d1572cc05dc181bd9266d14e5bf4f981648da4d7d1c0fc8dce373eeb9976b1f3c4280c710126019b52c2f847f51be9fe5b32f83c583fda9169ff2ebdd525d14e9cd75b6bafb01bd8aa13677c8ec13eb0d537921d072256b9869a4162bd541301906a3e964d67740ae6214152d2dac3f567ece25f787fde1d8ef07fe60f6c8ce1467682024bf3d420839b877e553c6f80021715a2e92a3313445089b4d848056a28a0035861b4ba5855e0b32252dcacaba2c85e800562911312a449bb87e0e9d75db2c2a4424a7cbb7235df2cb8589fbd9eae3b7d5d53cf9a059275f2dbd2f9ca1a494ac6952076fe2e159324240d2dc7153b4d13d529ba13ea3130254a7658ed256d6e1b006a118ad04d71092357cc57b72a3f496bce376bf86239cd08f9daf63ef5575c7c1f6f273915ce5f3e57eae76a6ff6bf65d8e83cd7f56f742c9b85affabe0667ddb19058d4969a8783e23544a655d063724b7de59db40a8b4d06a63cea89070c94d1669a4c695db9d36efc988b300e5c94043a1555ed8c8aa2dbaa493a01685f6ccb5e078fa085a65a968e383fea8f7825c14a3a5dc0d7c73c6186519c62db53d6bb48cb9ea005ea7f4e76e5ed2aecbe7327d8d7c0b308685c5382a34c69c9d56dc6ed3585d497fdbd634d91906837ac7194696a3ae7e478c092d457d5180d91b8b79947099a22e346f6e0befe8fd010000ffff030074ac2f3a30050000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "9607f407de8cd760-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 07:16:21 GMT", + "openai-processing-ms": "1284", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "1290", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999970", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_0c0558ababb0ad4c20131da9a47292ff" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant providing weather updates." + }, + { + "role": "user", + "content": "What is the weather in New York City and London?" + }, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_n6OA3ruiGLsd0hQFPQV9fErc", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"New York City\"}" + } + }, + { + "id": "call_d7kGKpfVm9Oy9ovs0W8Rn47b", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"London\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_n6OA3ruiGLsd0hQFPQV9fErc", + "content": "25 degrees and sunny" + }, + { + "role": "tool", + "tool_call_id": "call_d7kGKpfVm9Oy9ovs0W8Rn47b", + "content": "15 degrees and raining" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + } + } + ] + }, + "status": 200, + "response": [ + "1f8b08000000000000038c52c16ed34010bdfb2b467bb6abc434a4ca11504f55051481a0aaaccdeec41eba9e35bb634254e5dfd13a69ec4091b8ec61debcb7efcdcc5306a0c8aa1528d368316de78a37fdbb2b4f77d79ff9fadbcc6d7e7c587eb9091f6fefcc7a81ef559e187efd1d8d3cb32e8c6f3b87429e0fb009a80593ea7cb92897af5e2faeca0168bd4597687527c5a52f5a622aca597959cc96c5fceac86e3c198c6a05f71900c0d3f0269f6cf1975ac12c7faeb418a3ae51ad4e4d002a78972a4ac74851348bca47d07816e4c1faa706618b5a1a0c40114c1f02b2b81d940bb05807c4089a2dc49e7907c4708b5bf8eac323bc25d9e5b06dc861aadf78b69e7320493af37376d0c4c4f5c5d444c04d1f751a04f7ce4d00cdec45a7410ef11f8ec8fe14d8f9ba0b7e1dffa0aa0d31c5a60aa8a3e7142e8aefd480ee33808761b0fdd9ac54177cdb4925fe1187efe6e5e2a0a7c67d8e68b93c82e245bb096b51e62fe8551645938b93d528a34d8376a48e7bd4bd253f01b249eabfddbca47d484e5cff8ffc0818839da0adba8096cc79e2b12d603af77fb59da63c185611c34f32580961489bb0b8d1bd3b1ca18abb28d8561be21a431768b8c4b4c96c9ffd060000ffff0300eba7002f8c030000" + ], + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "9607f4147eb2e397-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 07:16:23 GMT", + "openai-organization": "elastic-observability", + "openai-processing-ms": "721", + "openai-project": "proj_Pf1eM5R55Z35wBy4rt8PxAGq", + "openai-version": "2020-10-01", + "server": "cloudflare", + "set-cookie": "_cfuvid=hegbUOz2ZUjELe2UBdTlLWUoLuE69OC60214ou0kk7g-1752736583082-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "725", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999957", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_31fe1ff570cb59fc99ef7a4ef91280e8" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-embeddings-adds-genai-conventions.json b/packages/instrumentation-openai/test/mock-responses/openai-embeddings-adds-genai-conventions.json new file mode 100644 index 0000000000..99c6260f1f --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-embeddings-adds-genai-conventions.json @@ -0,0 +1,49 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/embeddings", + "body": { + "model": "text-embedding-3-small", + "input": [ + "One fish", + "two fish", + "red fish", + "blue fish" + ], + "encoding_format": "float" + }, + "status": 200, + "response": [ + "1f8b080000000000000384584d8e5eb711dcfb1403ad6da3abfa5f57c9ca8e844041e22ca245005f3ee037499c5714a059cc82d343f2b1ababaafbf71fdede3efce3d7bf7efef3d70f1fdf3efcedcb3fbf7ef8f1ac7dfae5eb2f1f3ebefde987b7b7b7b7df5fbf1f919ffffeebe74f9fbefcf69757f8eb8f5f7efbf4f95f1f3ebed9ff56fe08faef4ee7e727fbd9cc2c1149c38fcff51e67effeb1fa0ade19cb9ec72a3ddc9f810c7a25e7b9a74f6726425637b9d1f5d800b3d12d8b66636e5d13cf58ef4dcc33b67c473ec99b158b7eeee9a86c963f57b7d11972538b698745c8b796f7844bec6ed4543d57e9554e6a6cb22330cf9bf94e7b14e5adc793fa849cddd87e863ad6229f8188ed966f2550ebfecc2972e939a50f601d9693cf0d72b280d5d8aa99929b021d59786ed0560e97abb20d6d25a9ee6c8ceee9cb80600d03a39cc3c264cb2b7bfa8466c4f35c33af836a2ca9a828d237fb79160ac935011b0d86cc67683563f4a32c5f507f7e7e770226501def56a4569cd3a524d0c14979136cf75ae3b1981669fbffd07b7d7e9ff217f474e314cb3332a266e92178b6b691cbb7852d569e9f4c5499a4f49b40e522b57a3153e8b9a0bf5829a7dca24fb53c491101287203b975514259b86c60ed037f9e149e0d6a4121bb72a9dce78e906a462649c91319111b2daf9fa74665d1c0aa8ab9aa34c3235d40e1d693f97cd5a44d8c62aab6764a768d5a2694fe59693051943d8cb8cf9362bd509a3fd6214449153d3ae54ee65666d4e3c3888e9bd3b78f7e3d5733acd82b94e4ac9e504ec774678fc4061d66b84e73abada8abb2d8970467c69649e8e46e6e295d657b9528455a6629b1b925a0bcd8a54c859898f2d4af3a10b0ab027d284e813eb4b93290ced612f28a2215971d68245a4110ee2924c2eec396522face25ade2042b65cd5bb742d99819037495895d2bff70e84d1cddb263bf7d2f96a26e5a39293332a1f05648afa45bb6fcb379933db56eb3dd8cfacbc93605bba908839bd414d80b5a3fa6236b8be00b3337b71a50a54bee7cc46859c955663bc192b0b7e051b8f31d1d2b49dad360ab9ecb6c9e762c7ba85f00e56597a0338d208f57098c987d97ac556675c80af1dc6b32e3d396d76a9930fe7b216d11b14679139352d4568f6d2d7793ccc4bb5ac1751fa5c40bda84f9cac59d3540bc68ebdd652de62c733343054709f7b4d511dc2d9b2544c789c78a7ab46da920aafdb1dfff4de7790584d6e565f1a0df8c6e361dea9a4ab56f4a467b817931a788a59fe9fe63ea271134b28bbbb59807915736df0a147ef8ea04e22149e469050871c8e16cc6639dbd5cf7a649bb7646c63f840f20bf3db5895795b34578a2ed12055a79356ab996555445c1a7974272ef5aef1855f49c8c951391c7043051963b9485ce21d37f7b9d7a013e2742de9c2d3c61aef55953e0df152cacbd283a142899c50a79ec697fdbf1ae51c04b566d3682214e7b3ca2eaf17050be91f02bd615eead58328ad655fb756633bec8bb73ab7dc5640744a5edb0f725c3ce1d1e3be542e90154a2370cba368df713e2fac714a4b10137d598c8507b4170d9c34abc55b606518616bcdecd0c3c3afab1fdf207d57ad592916709074191962527c339063ad163feb74822a4d0ea2d2afaef35b0d11d3b7a32fdf361eeb6a10d2677baf7ddf09483a8a6fb4b85bde1cfb5e47f49fc63b59d73065668a2970ae08f8554f111efa2e36e15737ce2433e699ade35ac6a05437072d8a20ec9e29c1a8f161cfb6925a045b5538f75c41363d8e3a95bd784c6ed5d53c1f339517d345d3d4f7f871ce548ba5207e7f6b3be89092ee725ec8e269154df4d6d2077b294077017ef9f4c3dfdaf9ecce809765585bbd579e46b574039bb6367517568c8cbcc67793c4ae563cfd34112a2c15d5ab2ed17672e71acb618e3116f76a8c2e9db770c3978acf4c4fb6b8063b9e923ac738667d57e79de58c6b3a11aff1c0e5b4cb9131326db5f2cbbfcf62a1e71fd3d66d6ab12c53cd14dc96da18d95483b957291826b58b2462e7ea81e8b5268aeb4c7f4ca6dff5720aa422c6266adbea9a0eb4e7353330103cfc27eeb310843688cc09b48e58e6e8b60cd3c89dbd061fe61d553a0f3ac3a072edf023e29cf79d5edc7efe37000000ffff8c5dcb8e24c971fc95c5dc87087f847bf8fe8aa00bb5038204445d741020e8df05cbac21b6ccb2a7fb4010a8ddedaaca8a87bbbd7c252e153b5cbbedd995b40ce21ca0b6bc9dada6b753b310dbf7097ad17a6cbfb541d78aed5a8c833f130cee176e417fd380fcc94ef4083e0ed36cd5aaf72fd53e38a3a8139fc80a39797bd7f468275dcd8567ae6323a8b5f6e2379e0580884fce5dc7432eaa156b0822c5513e25c74b97e734410cabceb4d4150b15aafeac1361d6dc41adcee05db0ab9dab8a95ee6b8a37e2c18722443441b2f041b2f6295f9b788bb415c14791b5efdafefeadce9c68632ca41d97323deab3f30ce300969347006103fa9af2b3546d741af4bb581e5bcdb57bc7e18d65358027e87c5c012e4c5bc8dd892f427f36a28d6fe55c56cbb882001bb19bb1b3c8692bed29facce61db74e32cc14952b87af5f43ab66c1f76c9687ec98b3f7505994eb7435c3a96b67fa716921eeb6c47e5d585d2b7b9dda930cb154d968b98647dbb573f89e9e2aba5162c5d9ce5ce9237d96ed89872b8fdb320557f44219c8d7e7aa5dc148c2f57185010cafa16ead66320472a8bd53f9cb732155f46023329a09b0d3678f3312d33d25cb18d5edac91030ee02a3f815e0e3c859190850b85a9854109c297419f1d74e8a05694d2786d549cdcd9476714d790e8d06aa8887d3c0857a12221b21b981953baabc0d731833d1b2040c8ce4673918a6cf8598b410cf0a2823738b009676e6ed564ea1d0fc287a101db3dc93fb783c23c7c73e222aa25f8d0742e7a5ab1ca7233f097d16d23b52aaa8c95c29780dee86504026fb338fc7537f00913de0ea5395d3d51b5dea9edbb4e8a13dce0d85e39ceac170032e99c7766e07f0c1a79cb8e095cbe2cce88dc332bb945abb5379f8fc038473a7730c9471a625bb33b04153e20b85a08b2073a564fbdeb11027fa57d5cc7cf6a3d07dc23a42a8d8e772efd3e08738c7b395c11da9f49ad7d7fd9f22e668901a1049dfae133d1b2ba6dfc8cecae1d6b1427403b4c87763418bee142e90c7f53dfdb2ba97e8d3d9dd322e5409596463737e0b4104c070cad30160e90806e7e0603ef06e84415a352913147ca571bbcbbdcb069670b7ce3b30eea475e57659e042aad1315c60dd044a0b2e6b21ac4d389cf90f21bd4dae9218cf255ed0783bfd91bc8488bcc6aa5058b5f766cd049bc8e7a77ad607c2f3cf8d4597d1c973f2f79ac192eee075c2fb597593e995c92c43a31f410322e60957f85c0b14b379a3740612e52dcc6cea737cff51b1cac2439e2c7515df3011f7b8e512315fbba117821efb8c0317e00d1c628c95a95b598630b070d2167fc3820703e0be7a010f6cfb8a41bdb42b1b0f829f0beb9fa56efc31c44995926f7276e27fdad86bdc54685cfc037b51f3ff4600cdbcb45edb60b50aab2e096f208f63adb9dcf23ef6deface64b48548b4a285404c59441ed2ec1847cefcd076c9c397942d7cbe92d8f300c1deed427caaebbeff384c0e62ba4d39ac1d5c727629c8d42f22b20f5d57af38d168176529ec2e970c694b607aebf9067b3cba4e2ddb189b4ca3ce1d2caf9a407dfa81e15b9770bfad2f5a0eec0ea2ad91c73fd13da9ffb98317a54678e0b77b0b35846697d1a1d2277371667cbca7cecddbb3636b260836199eecda88a592533c80e462ff8908c58297ce2ab40973d63b80e0893879620e3f031f72246a86031b389c38b393bbc3279dd778d335610db1a222c6eebab63446b08bed65812b277b2a2084ac5a2b75f1b8a5a2ad9575e1a112edab1887a0961b523f9a25b07e789b36e24a54bb7462143a5d58b2c5c221504b4299ceca0ba15990d50bc257712b4d7ce7ff6e2c1486ae479d0a2d033cdbded082c92078c13e18096d97b44e95b3eaeda805c116e4bee60af1d7d04213b768e09d9f1246d5ab1d3660b4b195195a1f432aa6f8639b74107c6bf3a642671e4d5390e4902ebe0b2594424ddcbf518ba7cf17567d5e9029980ccba5a48bac65174d117988335ca9c80d79ee175afe4eb0bfadb671d816702fa42816f0f648b02e8d900dae76d7ed0b0ba74edd0b3517faf18fceb291cafe0dbed03a9ad2f40c0720e770b330f9d25ba53b977f18745ebe17b8ea88a7b0fac0154a48cf79008e605df25b3336167098c0c459bb4ac765688e6a94f77920e6027ce4fdad458e1c1e0593aca575e07beaa93a94355be5ddff4c462bcdd2076512860a0cb587ad4c79acd0565bb87a5e22e7bbf0bbb6f012934570c54dacaeda297f6f1e17208abb3fb9caf9c12d6d0c01f5df5a07ce9ccdf58f32d65791a307a71601c784ef867b4c594379a4b51865a820b3b021e8920efc575f656ae1632fe2d5a880cdfa62ffb193e91c2d611c0d9c1241c91d9050a01564334f4dd5bfa0ddf5bfadb8552919102850b5fedd984372b1503e499943816e5ed8c4060d7c77221f3415da890947d3b37651ce17c65819fae2ce19c6b17cb1c1ed5a9e666c62229b07fc20b5fb07d0e2b499f5a4147dbb6b875bf9c3b079417eb6a963f9c88a7bc18b645ef5f64515ad069357f59dc4082cfae899437aa6ac195c276ad4d8b13ab780f0b6da64b90d188ec6120193d442e3e618008b931c9330595b254b9972a915f7d6cfc13a5014b6b17ac50cdc52b6a83e47df8a86859096657a4e3cf8da0a193394cca6df325fcdbda79f671eecfa2ab4459f568283b1b740a01e963b5a9a646e96eccecfbaa1d21d7d444358bd41f769567d4de2ec4feda19252585976da6a8aabdc44cb726aefae19327729d205705c41e4587ca51ee87e33e444601a53a8be5cacb137c9c34c707e499bcfa440cafc24f2064cccc6605599ed329702ff0a0437de9b68851e2f1641d679f8457afd582667508e5e2b0138cf1c39a41d12b351d3459dc913c53aa10112af6163802777ca647fafe21a906d14d8f9fcf28ffdb5757d9547c00216a634f5f4c0901bea38e1ccce8ec1d6400f7c0f04bd10db923ac93914ad8f204f57812cb795ce5b21c78fd0e0bdc0f258de539d729fe6ed47dd99fcebce3fbd7470551cecd5c4d3b0b6cd6f16e4675ed9c590c562c183f9cd41d10979cb061a7d0809555eef0341ca3fe893ee6259b3d71b824731fc0548c23c0d6c25b1efb3856b28d081d1e84ff54953e3082098d56b357c67c01609293c46249ad99ded3214a4a91145f0f617b4e8acac62f39102d39d4399bfffb6a3b2a3e8eb3207e31b117cd69eac550819b1fff44f078ef4ea085ceae963e387a455c000b0a4bd453bbc6b5ebb283734196b664d39c3e2af4ea2a584504f63915c29fc24d703e93445d17229e9e404c0bdc255c8e84bb013be2127c4d6c7eaad6058a46992b3356911a341fa29968b74a21e71d0b56ad036393c988da4591c5282b47dcf6eb2f9c76b12e2eafecd9f3b975109d2b2010310ba0d8d99f54fbf7a6816f8d0e640b83c92b45da778e892a1abdb3db1784b78269be7c1a7b42844a0e07ec0efe03b587da1d885ec75c4ca25da1e6d715effbe03ea70d620cffac567c81ee6bc43cd886c69b8f0d1165dfd265742c62909073f6f5739d2d6c69e56c556eacf67e474b5f05d79ccdfdacc5497d513412371fd2cde8679c9ac32dcfa3ec36703c8859d67639433828aca60411d8036b90c0732b9101725cfef5bb8ce09b0d368f62e2c181d7747d01605728eb6e9cfad821d81c1e5e67d6d80b4f879de42b63e8c54a7c222e8272d6304e01956c8d6c2ff141bdfce551ac60c7a37a17895eab30d2b75c33f06631a66c03379c49811d573dce5f35aaf32b0dd6d3f1a466d7bb62331bbe666101d8d34cd94031c0f227b73025e2e11cc2e5236a31549d7ac0f5bbdfe0ae6ecdd893bae01518e9d14f980801bc215c25e7ceb3381342d62d3aba80e1e2b343f315a5d09b411e2caa56f72bc40c63eb2b5db782ecafbeddd2b82185e233e50207c567c3e248902502af02652801bfda6231ccf564934bb8e9f8cc3ee83954333bfbd8b01f0a8aac0c620de169d85bfcd68f10759ede432119e80fcb043787a848793b340eb42ca2364c610c5d6546336cb26d4fef5669c099620303dccb6d6a6357cd2960aa9e51734ea7311ee085d799b80547205c1128ecc5e28a150d5cd6e4c450f0cff0a1d8ad881e068b887d89f8c1d51a5350c80a5619002ae48e43f00fd20794aeea4e56ecfbaeab02e0aba82b19c5cd855ddff50514d2ea4070ce787eeec594464020c287292c46c965e13a537bf1aeb958cb5d72c41882511677f0ca97bf50fe8934093a30585fa4ef40f7c5cbd6468ed38cb6d452c940674ef27b1900520a3bda106b1f16a54f87a66500888bc36be9515910c0258675fc4f700196f859ce449adb456f49711ad56724042391f5c084d5193faefadf341381051a8fd3a2b098bd8afd347072b0debc91ce439c63009f5b9ce8d0d362bbf595f01608e18f028e2b955a6d9ad983db9f6daf7d055348092c0fe52333ab4a67ef4a23ada869338829194231043149e49bea8c5ef68e3cd27ca7e1faa06b12bec8914b6a1d913e4321bb3807469c6baf3d04d834d8a3850691012fcbbb84e4e33867b7901c0e1387306d9970b089b23e11ab41d2c20117cf779a9fd9a61e65d08aadf28389b164f153740284e1758d7f125f28400220bd88b21f98ffbbb4aeda5f690dcc0095882c5de2185efacc935c56b94f380ba021f37a0f41b9f5df57d3c0b8eb6e904d22e2f39088beddfd2ecf7cd505685d9b13760a153b6f2e5c7d025bb65545f0473028d00edf7370c679883a0f914891237b642064636f5e77b1f4343c73d8788b458fe22818c9cb961ba9c6e7488808f45361de5f80021fbdbf1a00f5fdc3527237240946e6a11e81172d4f6448fe868d7319f57842aa0eff26cd9109463bc62059da1cf308e52c17b7f6ee81bc9fdd1e4f0d10c06d606a68870b9819ac5e07024beeef4dbfd3957502dfb01c5b8eba955981634806e508118dc278cec9bcbcbcb358fd0c78b142fd9d5dcef9938fa68f735234be9276783fadab6e68295346b249a493f928be332efb9e288499a9b9ab62a85c8ef48c6e510a6c06b423aa51f6bd18d99c763184014c90749097a15ed0802abc1f9dae76219e54ac572e154e1fe46fcafb23812f1838584816625a0c4a12b4e4dccd4eefc380c840913f9f866bdc1a3d83c082cb0c44bd1aa347353088f07901d9c10e4d1a3bb15555b9638e144a2b6d0465007c77a484078229ebf518828cf8bd76b6f6f47a737d4c993ea4cbddbf2462c52428eb18ab09ecf26387a4b4b0fde93e62f03bb8a4f97ec03f9c76933cd83460968262b49f635bb6f353dc975aeeae32ec58257b6433e09e51240b5a0975e40e145aaabae224828fa2a6205adbc33bc24fad1e51c91de8aae9afdae453fcaee3fd45ee7900c0136b95d0a6b6b025b571aa9306e1ecbd2b3fe9aa6f851afc9426047a4165274e5df06082f2f698956498ce9c900df133039415df7e05068b2d054703175d8552df588d04f78909228ae7c52996cf89631169b184a6cb8d5c9912d16842834d2b61fc58893eb44eb261fc0ae8d9962cdc717309ed7d6ca120540c29b24f27f7f57e501c48afd907eb5be09dc6c9ca05b94399c93ce7b12be09bfd6dbb256fc7033282f8429b81ceaec11b1ebe7563d7481e168ba140b4d811d46b6327ba8411337a7833381be541ff3aeee695ee1d59dc504503c8e24fd5f06bf3ab0704809c04a0a50fe7244a9ee02b0cfa2ce9bddcaeaa838d177586eab0b5b17ac5c1bd1c71d21a93716a66441680f03311dc5e815e87e3d5d91bf6b22557c591ccd2f1f11070a111fcc4f69d4e08a8d8b00de18e088fca3db7404b062083f421515e56f264a22c78697afb19c939dc7e6c8b784a03095e8aa2e8b1f9841f5a7fb195c8066ad1c90f001ed6a74000c03985beb21f7c5cfb38576e1f51715629b0d5c34abab9ee4ce656062e6461bd2434e05a707b15fb8fa44ffcd017863d3cc991ea0c3abd20c79d4700bed5b317671abf8e47890720fbd4b582e6b270704b81eaa3944ec683113014358c21408457119295850eb38e5d7c72a876fdaee57a6cb9d43b881de46d8b151fa2638e8a2d580c93edf732c6b9c1260e1d2cf0588201791c89f905de3223efbeb7bde7b7bc4c5c48279538849db9241ef3b1b0b119f40a5b9213de69fa971d7f64e0805da49e9843a0af6d5b8c5e77e147e7baf909405f3527f679882cf1f31e0af1fac5cb25c866fb7bb8f92b2958ecddb62f879b10e3058f2347b50dd2082578b0910dad71466ca7bce7a3e4f190b61bd31846b4940fe97e57e2891169f208e13e6aefe11eeded1ad20a0398d28aaa2bc3a9813ab644af0740e1c8f27a48ba849e3865e3c3045daa2304b7ce21efcf97e5a3a3d161595e2a8a2a14be5b5c2028e452fabd2b6c56e67fa021670013dbce43c123c53a1db2c310d70abc0dc121b64fbaac677d5fa0c567d1e2bf02f4f52b4431058af36049d4142a5c06b71168c405be18f71f1d083fcfaef6665416627b53ad33b6cd5af1053710c444b6c605176c4cb1e18e2ace111dfc078fc007f1a4d25c3f05913e7a2e7e9aaaa4bc68dfe2c55119f2ed1673b5dac3c7c109b11e137b4bc7bd2e7f321f8960f373b1161180f796a6028c8287e84738e2ed11327d852a46b2fad4e6d45ef5d09fc7c851d9bd43c3c71109534282f694443e6c0d31dddb96446e213c1e89789f4576dcfc17b28e7481e7590fc0fdaa5acb4ad4dd0951a4f476368b430190042b40de789bf8f9f11df8dc39e6f2acc370408892f09ea5c58816a04536c41bb4100c383c4ebdd2a8fb0f8b9e076ce2b68c2320ec7c1e5cf31c528093b7436e1f5ca18cbea12b8d254e2d883ec5db68b061aa15749d3a92b56f082fe042dbcf15242a953a707e545e5f31bfa2871aa6413f48ba79d4c4a2f87d0f26ba93b87c49488ba1b575253530148703592e77dcf935d777d7a813520a9e55ef06d35fe49b6cf84796f0de50f79b4b1bbd2ba4e449e80625e20d8a882d306ebbf76130e7b255332267284e8a958309026944180db8554228a11350e5dc8d8cb2b4e80a69e5423dc64490d72746753d086c5a9c6cede1c8709645f4a01cf475a697603fa03a5286b5ec49d40cb464b3a00f679b47e5760ef4c5c0b1a2e87c472ea064e476c25df5a757e72fb0d49cfde3bb149d557b0bb193a0f845aa72d24cc2871f6dd93010493a817a1c5edfeab008fc79f8ca3a90ca644a9296cb001c440ad13d9f01edb5c046387653f2f48ef791d98007461f8d3dcbab98a68cb55d3207ee12348b2508f9498c4e2ccca0f4de3a0b025121253ff605fb6b5d368b8d9bcf1dc2b356554766bdca221b318958581fee52e0e714d94442a421d07ecd59ef9acc67e3c6bd8ad0436a6827eb6a3ef2ebe73a87c383acdf3b89d70533b635f6813d2e376202d423f617ccdfd08d8cd0ce76b0e5053fd5d0f75b0f617d96b0c1d756e02214560a112fc8e9f2fd7156cdbd659789cd340d19afa2c62c74b54c8e24cc71545728c5fc58d7ddeb2de1aa151d388c1be72c5d87e46dbf6b104cc6531590a0313f939dad35a70219c8a2da5a53d2973e0e5c7c9438a2de933453445bc9f02c83f150761dc402d3fd6097f5947c0147da0b8ba67c1f2457f24df7d4e32c0c51341d19840652864c1534fdbc1965ead1cb0816a936c1801c5a7444ccf5bfe24e0a6038c72af51ee67c9593fbfe280d7e1cd9774f47c51aa71f96e30eef1f66aff7acbffba6081386e2d12dbb2ed9d1a9118df85d2db458c12a541809524e9a4ac450d5087560db8a93271f57b28337e8cd28fd4370a56d5055ecc6c72065f5cd6dc03e2c7a8a05dfb8545c755cbeaa3a30ef9a717cb68cffda9621322f38bfd9cd8d01c74b52f57dc5b1a51c7f03ae24735243b07a982e9cc3165e6bb3e0c44598728fe46b434fb67184f3150435c3f4170411860b6831d8e9383418307ea2022c36b6a7ce893df8686c676fb7bdbe12c804c9cf5e3c79db20342916f83ccd32bc9cb9bbe5e8c6cf22c832b4dcd9c93823c2ef058982eb4d86a1083f7deb93e1bfe768640c83e189964fa36e91610d9508e305b6b7103fe803cc3569eb292a0b4fc058b0eb209ec4d60aacf8dd09f4fd431806e8d6c80c61f86034106f4058aafead2f9f214b2ca8fd7cbd95ab1d30115ba91366704896f8f98f2db144602a69690d012c886b7f41f71fefff57306f6c19e20c047c153b630a9604893e7d0efcc507585274e56c5570cad88f5fc0083a99e9063c0c7b5784d73a2e60ef425eaf1c3b07963cd1fa1fb0b9acf1a6988357e58bb117ec946dccb11555c8b3f08ca3f05e641df2e719c6b82c2432c514f7d4b02dee4a6ad0f4a68ba3e13217091f4bd5d41891bb4631648bb1e2a3b630d151c28b30c7898b7276b7dc1f006d2c27c33eb6fccbd7202c8145792b301be8fd55243de123bc5efbf7ebffffeffe57fef7f5e2b7fffaeb3f7efcc77f7ffbfdb76f3ffef3af3ffef8e3effffcdbb79f7fe5dbdffff9c78ffff9f6fb6fff7a087ffa977effeddfa89e354439c8382ba4354a7e4dc2e92e9683c15693e91ffd605a5c18a75052ea438b2591d498a258ba4b4fc8b0984b21467700e4ac32547b4f77aa6f38f615cb23f5300e468192a18b8734928bdfab2a967bacf6986093b8301862bf2ed82d893970ea88032ef27df4d7cbbdb324e11fe329472edc56c10d8623092678a5e56c4576b3065f8ba709e05c657dd05c7880e4fc61242f7d4fef5dbe7464e0cea0bad307159e44de5f3364b6c6765cea37b9f4bdc5d486ff5e343b18a756c3618bd72c46f1c8c611ef2f02da2c58a606d87e132a8ab04931812215be374fd039944275b79a4ba62801e43bf690618f9a8561d27406dbd1fc6e191a86d9798b43a1bc5df4566197aa9e09ba45735a7efa4f9373e231943d43402bbf742a87018f8c1496142a68898c055b115c159d1baee0f2033982ce972c2413ace93de96a3a8bc94e3549f608ad7fcd341ace9046cf88c17d82285f28236d0968b65c5b99558cd3dace786f112fac04235aa939c05c06731e7680795b6c7ae7b7b9074256b78a303048c55aa60ea2dca6c22776bbcceeb96411552226859045a211418d09128821ef4b47026d8c04609d53cd1253321202de59cbef3f733a304f25d5f47446acf72777b5d44380d734cee8d2a2f0b3458b2672afd96c00f12b2c56a2ab3bcfb854e031e298439ebd18ece38a85139d0072f8245c1d4661197f80f22c18260f6c171d0bb506beaf9054141cab0400452702868e18b043c65540ac572c45cc53579e22510d07d3e359da12b93a35773480e2097f7385f88a28fa52f7f193697fbfc2ee0179ba346d20ac138ce474710101b8731f99e68bef3a2c86da81b64234809879b7647821d4acd2c22466508436b8b5528c830bda4819893d6b24f2ee0af47e804c0b52468d877798ceb75847116122d373aed965a5f4b9506ef871b6efa5891d00e0391ae520a56024c764f19a4580ec2a8d7d76973939776bf4fe1091122ef965b837335adb38707132ed32a194db5c77f4c4917819e8badf4fffe7e77d23ccad4887c1012ab32d31af53ba7ce82a343b42aa879f9acd2521d5de65aa118564b1723d692b9aabec7373150a64d6c4c3e437f3226c6957f969692f81e6aae12a1bf2292e01e043665ac1e0ace413f100e5e7dedfaddd74800f5446a15df38cf1a31dac0f198a833e56466c7543d04a57f88ccb20524cfc66374b8df556a920e835cd4c764c136bd55ba670e78893e816153d56bc18bb1e5a2a74e190f3d2af7aaac4aded760dd1e0b3a1d382551dd7d86619dc80791d2c9631bbec726c77461e47f05ca9757cb1527166467e27a97fee394718c1452c624357be85ce294c78964fbfa3e2c1f813c5a3803df0a9d8a88a78f5c32a452486279b13a0a9791fed7ae70365b534f6066370083c8e3cb6510dd29e144a0cbe5cf11bc2d1eafc5e1b4c9930ef8d69abdc595c0315474bd34055618cff19406366ca8081b6f8dc3c78ba0384b921b3c6217a7e922cf76c1d487410d627397925d88ca3e29790b0b9a2de446e8bdd424d1ca2f73041fdfd82db133a56a19039afe314710472eab7834e23f508467249b2030aaa94814553226abadcb52e939fd243d35cbd43b32df7450d1827dae5f696146938cc5a82d770fe4f7c525dfc8c863847e7f8a174f25de2aec5e8ef923e1ebc8be8031174b704e5441fb874e86e5d01b092645903a690f7373e849e25e8ba4eb3dfb00556cecbd6b1bfd23563d8bb82f0815c1a35b1da6963fc17c6568ea5d9394b91f56bcec3b0301a25a95ec4c8a591594811993b5dc612e024e15bcfd556684043a46bad7818cd0aae19d1c2bc15c0f80b3730f8b6c5c91159c3e1edb8cbfe1c93fbff000000ffff8c9dcb8e25c971447f65d0fb0122dce339bf226843ce40a000511b2d0408fa77e144668bba6651aae18640b1d95d752b33c21f66c7fe11e11a53133f802e99382d27ba62f30f6e9822a6689d58fb3dc213e6a1630b662af0d82abf47e2cf25a7a30b06d775bbdc78b4b4b2be1049661bff53622c03d32162eecda085f2ddbefdf4f06cfa6b938f0c7937ddfe01e531d9e19ae867a4f4090e591b7c26f00eb9bacf09afd2f4202d43f32a981d1a7d77323d528547b69843cf00d22ed67014e83a12155ba237df4822c41bb6516c1c424b4b22388876c5304f75fb0cc300150ad78c3e2d819e38ce6eab0ef2918139ba33b24fbb1059b6443709755b4387b51509f3251b6b00c6a9b6bec0115fcd3891d3e2591a078cba985adf8ca6f534c4915f2d30f6a0d2cc925fb7064140c6a1c536ab61f278296780599585350d80135a6960ead49f0aa46babb6697d0a538b07acfb935ffb54755cf776ecb0e2b38412fc20269d4cf0163e99c469a25eb622c48bf7df1fa1708c88d20d32f6c553044fb4864eb733c67298d7e8ebd32efa2cb6ca3a2a701d858fb5b6adb6d92c99b106be95c53eb0fe6513afb7cfb682b10697ba2a1988ea5b32351ee79153fa5a0dacdf2637db2d354094d8e2a6cf201f602a1c68ac7209128e6eb3856326b1e8ce327396794176e6346210ba9d50511bd8f3bd2d61677669772743c16ad69b350865304728d37d5d0fa0159f1a150f35df426e06bfe661120c72770c44d58b2ab7669d6b0f4582f44f37d099f3e2b4b3feaf1775f5c16addcd820ff24c9a659438264b64fdd5e9bf7472e7f930e4a125a64dff2044a9e18212d4e47a7661852b338d9134238629ac39b90cec553a09b51e050b1bc80699b41f59cd0f7cf2417bd17e9d8c2bdda2ad8127da08aa87c9ed6973fc6614c042be98b1b27b0729adcfdfe0f5d7cd44a374b6379597aa1b76a1ef6d8d3196eaa97be15e51589b02b6b71e371c05b4111da4ec9ada98778cbc0a0ce319d8d667ed1e4bb93cf5e8bbe4f461e46a9e06db36bf9ec6ce7a54db1c66ef36c83d6a3c6ba22fed361f938d015913683eef409761d64b0802cbc3b0765f7d28c8f0c0110c75bac31a5ddc1faed923d97169bef1e1ecf490d7d06763afef2e9882a93585bf3753ab33e6fece356a735775d2b16ddbba00a36854fd18be01cb5c6be4024dc7a7adbe6d5a85c9bfbb0a8ebb52ede5c1c4d6f3634fd0eb76261a9a1593974cce4c13aa6e5612c6231c47b8a3c542b350b71acc928b93cb3e3bddeb12eddc2df88d6d937a8af46291784087bb67daa1b23194169d72b7531ec7758961f800408d364cb349ccb390c04c6ef4486ac4b4a02adc3132232326cc03a95633f8e2710d58075cc99935eed8c8b51df35d07dbb661e0e6f89c3abde895a95b8e1369d22ca9e5542320c8875ab9cb54ac27350ab665b9d22b403015daecb635b88af8b85aac81476762e3774aec61d3299b833c0fc13a0806b384b0eed2b436323a969dbdf75dc56cb5f7626b89daec3ecbe34897025b6406af2e8fc35387334464eb2bcb78705988789d2d9731ad995e6ed5052565aa82751f15f9763ffe5140784bd4893776eb5d59e97b5f5414b6ec3f16cc4c53bed3bb2c7355d475b2f486ebc9b7a12d33e8796d61cf9dd50d249fd09ead119ddc791ad7c0cddc8b16a3942bced66ca1a171cc9c0db77084288ae2e71a8968ae9b61c16277eef57aaec7f76b32cfc930418e1fcc824b4dbba6e47bead0ba86697519c818bd2b5126db7ee47a5b1cb485a7825dcbb38a1cece26cb9a9366ead1054e3ba8663c47938ccb303d1b84eeb1f109e7a400812ba003461ddf81cdd68c5ac45f5d7939bff415fe04d40b02db5f1564c35f1a340535ef562d96e888eb9f38cc1fc071edb414ff88a3d926f4dc6487a93a082756513b4b06286225d5cdc779807e249cb1876500cf202cc7d3b4ab81da541565115594c102e91df6ce71f813369f2f63c8d4e7d686343f6cbbd5637622f3312d2327a2ef27deb7822e9d206cd197da58bac37fe04a31c77e0f8d5301a107dba63a44e8299fe0d04796b33062d7dd5e10c8c09e25747dbab4f2dbde1fdcde1c2f8367bb3d9342c17058410a9328b328c782b96a32b31349aeb3882cf4b652113188c659ff344dbd3c03b3187daea937a474fb29d33422b9e8a5644b75c8722e507ecdcc5b4de3c8ccb368a7c2e36e66a00088c607d5249cc70cc0cc608043929175c3945a763ce1ede91d04423c65f65a990982320c3153fac936c8c82d573e828074b7b95857baf38a435dd0f7d877bc9f66acba246c7dac5e0373a817b540ca0945548d238157dac81c1a3998c734c2b69d03ba5897e6050e9c88d97a078f233abc766cec38d92da0b6c81edbd68ac56bbbd5dc0e88dd3435f1fd24313bcae7ec8c14ad3dcaf88de5bca8a8fecab54716cd6598ac9eb7b4749ec21d7dd52cd1071ad6197026c30338c15e092db123d6b6c12312c4a327ad80969abaf2f37f8ece9bacf71836fcb68132427b790ce8b97d00d16d7b2364e58ab6fbe76cd69f98ab7294480b0ddbe428647a0c6de36625bfe37e978759a6e9bd62596e53cccd694e14082b96619c73ee3509b84ea8173b507e19c30c9f2110ae870cc7643ef3e74d42e353539efdb7d0feb924b97482123ffffeffdebc5f5ea27cf5cd930178de9e451b76ccb3a501bd8d81b97b0913e08687083c8ed0cbfdaa660222f339876e80ebaef618a36eab64b64f55c72327770f63687050d341db1947db06b7732191385debf7179fc7addc3bd9828e48fa67d1a80f0f4155aa70f52f16babb679e119483986f3b18d6a4268cf3af7523031f9c3ca99445804f8c0b61f98d7fc86c78cda55921a784514ea7511430561629a39c5c0d184e1b707dedab7170a8df04df36d673530dc21d9a76e6da3e0bc70f3f740ff2b374e8f625cb8862e4693ef12d3b22ef37902a7132a0be026356880b79a61e2accd2a7068a05fabe6d7bdb69331901699e06b6f1c99f2a372052d537f8fa8ddc97aa0e85d9b34f001d8dab522ca088351d2abaa4e7b646c75a5f506824e8c1723ce82dc3af5d10c1b8c283a3fdda73f63d3d933cb25c28aa22b62b2af699256769db1e5ccf749db7b4057b77bc15d8a7d49a5c0cd61844aea29a5c7acb5977ada79868cfc1963b511e1d4fbe57e943e3f6d71f7d69df447ea319b28f0557be02a76bf6e31e457bba2bdc75fce4f886b820eafbf171a6f39593b924cddf2d152595c1406ba6961e370fd7a77c3266d74bb640546a466256a5ed5ab7a431da62975ed246b3ac4702d1bec340e482be9cb09abb6f29f659b654806f68066a0345e437d0ad8623717c6ce361884c9b75b27c9ce527e9081a1eafe49ab9761f30552c8cdcb78feb8e593328bb0de7a42c957683cb45879e7af870e02f265b059660badda22611c93aaf61a4c125da2f6781e4d9e8550d0b27319552fcb71aa277bce763fe25b7f3a10bce456420eea77bd12e8e38de27ea6a1ba1dbec8827d45f4b6bbb8f975cdd6179dad894918a22b1a6a075208a7a8a33ef4e4af8cb97c7ebcd2312d68f456b7dcd2d98a11410f7bd34031f349c29444dd84c85fbfe92c7e2602af656e86ebd000a4c44cdd0b1246be0d60133801ad66a735cca2ce45620a72ba46bd3593b4308c5109e8d547870bb0560de3c13d66ceb8acbdb7a25702630b9d6ff0b316bd679767d1232e310051dbb8df54d95d1425117b9374e9e9f0837478a3838db1c3d87b9517c341d435665ad0e160bcb4ccbe8577c6464938ed963657c944b4ea9a61a103a8df6d901fba23d7ac0c1e0bbb434d6b3189e433332b5b736518ac7f2a4f5f23c3c2cb60c5fa4dc8001077bb4d864950533da67df1d5bc2f96b5ead5fa943c3c729e82e45dfece15a63b2d80fea32a731bfcb0aa0677d44fbce6979bc56ba7f4bea77695af6de0fa80e5a7a9060d09fdd24f9a6ca516e6d262e8e94e35cc215a93930eb88ae223fa27b2f46dac57d7dbf1e47f969a7fe6a9683d87fbd94d8bf99407dc38060d0438a484fcc0bcd71dfdb49d535c8f475b3b338259bb9dd6be437c24e8e817ed5ca6ecb1cf2b40f3d869b7fb9af6711105d59ac5d5b42270d1eb1df0eb9542f2881d96293bec6bef3d0a1bc5fa2d54dd55b92858848683e042bb8259bb65d7f99bf1b5c1a9aec11cd40eed2f8cf6d8f22c65c555148f7a2c5b845db97b441657f032f7747816b7965d85e82a434f0da8f13668e7ad719f511d2b7522c9dcc23aceebfcbf4c129235f603b524aa135d76a8c4e88d8540f56f3ec8b30b57169d19e99eaebbf39aca6dc0e84a99023744092a896aaf228b85b0dfc06665971e7c35999458e0103fc3d2eaf7ec36ac260090675287c1816c5c4facd0db5c97149a533f18d60d5b972075696cd6f588aaf8bfba8f340f2f4e3f6be6fc0a729facdbd3151955274f2517b5af312d31d34f799793bfb69815936d9d2d2aea8a69853e3afa19ae8e9ed5ea74089c6bca6f70b029516d025f4b59e0344e5dab5c1aab07534cd77a62c61df01526d13a981e8229a40b06f4a0ea7e58045a8f9471e06ba60b5296d6a9074f5aa116794472ebe73fdab4ef3482665de3b2f840ad0567f7a2092af71d1c7b2e1ff20ef4579adb762cbbad58a6b9c9429f823897b5c554c9dbb2558e0132edcf6e7c2cdbb133469843805114a0dad7dedd3aa7cd7165155512cfbaada227ed5d7b5d7ed7a69923e2a1d8c55f92617f973102c0e46ebd2e3aa55a0dff344af5037b8e58da156311d7b3d58debeff5d286b961ea095a374c9bb2039eeabfec0b2d579c67ef53b8b627218deca9b76b3fd654f993409a7c2793e1981258ef3eae78f2a4b2197e159391adfc0e6a5d7b1ade2d8f7822e643771028c92f58489c1bcdb69328a76c0700095a9bd8e4ffdd3493f12a723f3159a3685dcd1ac4a66ed7d9e7c4fdb2d54a8e97dadcf7f43025bf81c23ccab3384a723d9d79b59dae0c1967359388c60c5f879e10ca95ba734322efb11cd408463de62e4adb1a2e14111ae88418d26c7aacf84bb501704f0f20e63aeb76c61e6daf4b1629ecbbee2dd11bfae301eddd47c0891aad793329789067fc8a606ae884a1ef70d054cecf5ffaaf5f0e452b37a7e9985ce6ffc4f9a9eaeb91f9e7ae312cac020da1cdf94a6390beaa2377e7aa66da28274e650ff37bdfa4004c3163cb0811787f9faa8559cc6b1dc8c6d5a6764450cd565a2617b48b734f68873d3af8e2f46e4289bd5522895b3f8d930c25a5370fcab9b19288ede228fe334c8d8eb75e9b65a45bdb1d78383a4d4077957824731c3dad165452fd085454f718fe5199ed6fa880af9427225c4883d2d552831793518b6ae07d32316975996c04e2004d39601b61248772821dd57dcc8fb4abb139f1b4e556c433c3415b8f0fca2e6b88f56e7c7ba1e58a5320e6d4e4ba278ad58c82c4179b0167d4fef53a33d94dda054fa3aedb4da3aebd7e0f0ceb5a4b2089b3a13d93b4ad53d3b516f221fd1deee659a44ea47be3c9aa05da80ec54a9dd09b037f2c6b579aec933643235ae15d77855766a867a2186a45aaa5fe95489d9befb2bae3ad89f62eb9de69c696d54051cc386afb3fa778024460d830bc05133483e178bb5eaf8713c0e7b974f41f4d35380d9b2157396088f1fafaa884962ca7504c32fab1a468839946a4f2e36d07b8deed48a9f39870027e520a0c330028d134e9f84803934e4af74389e4e12ad3d7b5b36f01209dc1ba21c29153dbfe8ed1192761bbe2c5418f41a56cc96c172d7c8078a61cd0badb62b5661c4eacb595b7c1abf1fbac020ee43e9022d2e51b297ed41b07d4a173e1cd4a3ed4e8ae6aae036f82c5a6f30c4d772bb7a4cb7ee0fc605b6830d64996940f14d07a69070421adaa557f4f6e5f8bb87f9072b63725b0b5440f1250d92303e41033f1b98451e9c89bcfd5d604aee04ca8ab84bbd2ff394de96b78cce5f0f97380a639bba64cd6200ca98f5730f8f8df633bff21d99ed6e1e0e63f0bc26d459975348110549f53200b2c4fcce04fbdafcb80f75fdc05ed93cbb904541cd4b4d4755ab409438cf809a152bc34cd598dcfc79377049026537af25b12a45a5dcfc6733e059467af1117339f841fd591b2a9b6e2b7fc078dbf8568cf4cb9af61ee83cf7913b655485a7b543f6f1e6092d9c41d96aa0a352880ec7acea4eafb09abb9a22a2eda2e6c772683f8aaf6112a3998c1014adadbe41b8c1ebd6fc8e8bf41aafb28cae0520ab8b7aa1dbb387b4c8077442ee49d581ea53709fd8f5a117c7accde43f03c1988cbd02d1ee34205ab2083431a3913ddecb6facf6a768fa154cd8301ed98df9140de2832f5e999e0f7d6b8083f4aafd094e3933e1d54a01a1d8295af2610bdd9f44571b575f2b595abceccd78562386c1fdd8e9ea1a8a49d3b9f10c42a7c4e7af27be486871a1e97b9e9ceb36a8e92376d50f3379d39df2947b58217050579e9d97bb378347759a6df5b05f61dac8585b2c5f37e3bef240cf514bd539c2802d3c4c5ec151673fc3497256843b7b2e7b1abe709b3a4cefc00cd3888cec4fcd057b67b03828f2b5b3548b4abdb2796ab2ce916731c7689a9264edfa3b8621a1cc2680d502d94d16fae539c5d6665221d910658745719fd6a7185e806ce4b43d67e56eecbe192734609b338ea3dda48a35a99ecd76b90080e4b7e0866b1bfdd4ffac405381daacabda7632676435a58da5143da34512b92fd3eb6ca269712dc16b3accbd56fdc609f8fc9da4bf3a346193126a4e1edcfe17dbe405a7867e711909e6eebf250accf09e59eb71cbaa0b866f4c16999d5cbcece6af821063b31fec8c3a4cc67f5fedbbbab9672986fa747c4f606fb1322918947719f5932ae490892ff898638edc3aaa3a7a7fed3636b1244b15b6307a8a2f87e4ab8ff6b9872e51c8296da1f1717b0d5a6f3d3c31382901310efdaaea8292bbd8fa0520929a0b7f55c7612524a5d9f4ad142fc631219bc8aae5ddb616ecadd2026caf74519d99d5967ea73b6247c6f65f0eac6f70bb727c14bbd81b0ec1c936cf6c2fec456451617a00faf1d02002522feb85fd41276a653537c3fe53fefcdb14175d3e4a2efd5c28224cc96612fef7a5ddcccdf53304ba29c6ec2f4235062600d56adccc5f008d4b31099306013d4de774c681c9299fe66e145bd05498e9cb510f972889c93f1fd6767374dba34d5b963aacab89657e5bf002dcd8d4b91e7b23cbd87b29a0ea94a3b5b3256f450daece506a030fbd9c31ba617f06acadba6c7f8e2bd2a0c0ca33f9f56788bbd561d75997622b9f577e9ba49654f73e0d13c518bd999b8887ad28183b4f7169315f4ac87b9bf9d00ce1e3682b5be3a478047671e567cd90d15163f453b40f2752cd206a8d05b31656277a43577fb7e3392631d8c55a2dc6daa987d34d0a49f9c6a76dbf69d85b7ab829c9f10d838264a07be87581130436662d3240bec6ba64b4237cb5752085a14d7b592c68ab988b0e41ff31520a7bd7e265a3c0d16e2ad92198fa200961aef6bd567b0308e56cd33654d77175d90c05f5c93e8c4d07b5a2c0d1393ec7b859f2921823a511bfdbf56f5bcfa75aad9cd95dd178928bfc36bf47d7a13f18d1d0adcb3b8f4ab75d4a1a1617fb4f7cbb81b3ba59ed4108eb30cfcf28ade9cedfcb84674bb7b667459c14424f4400a333f4e68b8676569d99fbd096e443a4a6b365f9a8d5be5838f5ba1a01067db6457113e254ddb3cdb2bc7ee37d7ba52665b616f99d90e967b646b5aefcc45e4e7d66eee4af02c1c4dd10d7745758ee56014275d2e3ccdcd44f0d1d9fe17c2fddbd8ca1da51f20a15e00ce4acba0ff8c22df36c92ab28fc8b848357fe4c50fdb27cd3aca3dbac97302b2d946ed101b55001eb422957584269cc38958aaeebc968a836d6e9c508aeb77cd164bb617d056796e5c9b1e4ea614eb2d90d6449996b4e3a2c8adb381517a328cee45df44161c56d04dbd8042b5a85740b4dfbc2c972fbf419eab837a4f10859a19b5ce846c1bfe77c7818d5bb11c7666f0efd292b882ba10095f79e9e8fdc268c97e9a2fcd0b544f0078d0f06e13447d7df014fe5506506fac43a554c796251a693da2474ea75518c5df513e86867a785fc8e4f22c46b451fa007be7f8530e20f6b62d15dc239d042f3d1a419325f92d3de8d6cfbcc897d9a3826a6b69cb86ddbc12045b39025827156750f04585b879c8f61c4168c243b2dfbd9c323de41cf2e8ed75b4c2994116d9de8d3cc4753a641a76037c4f866f06dd3fb7b5ab1e12e9ed274d5305b1953761b8720a05d7672308b5557585692198d6d7c9b87d4029bd81aae01acdedcb1b6027ea6b9182ca60b2c08dc521fe9eebba7a68d0431d21a50b30e4d490f1e72013467c187985f1268eb1975155387f382d8db7c8d188dd1608dfbeeec3614bf6edfc0a664d3254ee0b37165f0b9ec3d5af6a23c0f6e80baadc761022477d895a5c2a73052510377655b9e1d99023b1194ecfe5d2ad6b388dfa1788d9f41f6efd7fef9fcf77f3f7fe4bfde2ffef8f7bffceb1f7ffd8f1fbffdf2e38f7ffbcb1fbffffeb7bfffcb8f9f7fcb8fbffdfdf73ffef3c76fbffcef77f07ffed06fbffc932c2029b75cb2079db0a4a7fc440d5df94e7859eaa80ea81fee0c8a8c3d9aa73cafb995efde56df5a48508837632fa32dc4af213330b61d86e63a0e641dbde7ccda8bfdb013d854d7edd4813b281026c858918f05cda1f632341861eb8b35728daa34fdf909ff79872ac35092ac16bb82e11b3e0da39e0cb2146d52d450dfc86ddd6a37d145dd71725744f0007f4981918b487b7594b02196df324e33f380e62304117ef4a05ed2612dfbd65975ca4288b5a15013a09562a5f107434cd47bbea2fcd1971dc5f3b22cf5637432f7cd6677beadd438c7f1e7cf751072320e686dcd2517ef84feb7546dc29962d924ec20bb535b1be37269e1e0096ae246468e7d4b946b5315427d3682202ce11253d9b0899c3f66159bbf0a3dd133c6b0b3f6e81c942a8ee85df9ce9b6ba598b278c7b0d4d44cd09c364901cae75470947bdb0479e394bbd3a8af7d51324bcbebb93763cd806229da8bb9ebb2cca27e9225ba02bd461b17851848623b280373882e803f23d7df1f8adfaa54223d4929d0795e1d7518d1ac4fbeec4a4bbae8a97b0de0d25a78d2b0db4cb31cd4f234a25eba123e818f5921d2c9c0f0368a24786b62585a1aa72b190c57ad7a7076571dcf4400375279d726c2dded08a8d3e3c2d6376f6fdf910adbe360c04894176bf5b06b0dc180a936ce826ca890098245b3b1ea6cf4fd5a69936664d1050ce970957c7361bdb33762816dddaf8bd38a6c456bb6beb9d8aa87e2427b290e75cb545c4bccb9f75a9a02098446ce31088cdd416d1361af99a33ac243651b2eb8166e7d4379a717c66ad3f80d151c43387c9a3d865164a7e151a36dbe278560b6e220ddc47ae7f9a86890bb9a7b0f9648ef8131da30cc37ee826e26951661a9e647e9d055ba19e0076d6c83174e2b797c23e9c69536e7b03b8fe0c46973cb3a81ab98a68cee49812d1dd3b2791749dd1d3647a0578c66a2368ea78bf634800f4875c8e4d5057731b1e35989526b25d0231ce3a72b4232387d4194ac9dd6f6a9dc1abaa6aed40e99961e32195f3a228918051b55f3dc0ffb1bf8643cbe832adf34a487bbbf3d5d8523d1a40da36d3384cf8d45ce5071d03e54da703e5adddb003149030a8d31b65bc9e0527b138ba5a19be4164de7258096b816ebb558492ecbe824f03975adca4eb5e8ef61b4995bf6e29d39a27f0363314b553f5fb635947717ad4ff5c6766ce2db73bcc76c66f39443e60924442b6114c1dc975dffecbd7992dd9ce64f46d7a8486db2e82395b65200dd761f8731e4521dd2e7e3f70a0e35edf3703a2e92061ed3d41f1e44726f5a0eb4be637b92bdec481f19387a5e9f8c3702d0f536a8ada4cd5471e91b7de1608bba713188ff2abab0589b39b8e90f1683afb0c92594245b51b69ace70c9a85d915675433bbfec81341fef7d35470d83b0275afce5bec3317a531ce822a54ebdb32351b83bc20ea99eb47c2c5ea4fc1871c8a77a0cad808d122a14ac54a1366edec4493ae6691a2f7b22df9edfcf6566ccbe74a94ef1d4cbb0d93edbc5a94bb33cb80ed98f245786451fef5a0d0ce987c82397e83b65c5789d0b21526f06d6cf1ef47b26d42461ab9b72714f8bdb664ecc82c32eadbac66edf36cdcf4b875a54c58727b0553b036ccedbb759ab592ef738564e7540455ea26dea5abbd90b9e9d2a58ad3ee81596a77135c2d9941b19506ee47c252a65698db02ab0679d3a0c94aa1ec3b387ab93e27f000000ffff8c9d518e64a90e44573412606c60ff1b7b3a64ce481541bfeadf51774fe64d2e603be244b5a5973df48c1a6358bd2af4c01a378dc2ec19501b7c0e344f6d6f9a21c058e1c067acf2dd00e1a7d7706d31e901fdef7aa9a75a467f34e087e6cac25dc52068eb3081bb0d1f0db8b1602085d67171f2d23a0ca530203c44acb6eb5c0782a1d379e9dbeedaabef3b97c34c06a9c95a370d0e356fd464ad73341721ba7942b146754f16bd712f66ef3fa4eda9a26befde759cb7d8a14ba5d879185b297b28730c2d4572770fc1c1ad5fa5e2b18454252714a4125380d4840c20c70ec23d9b6d916bbeb645de9330ad3dddcbaad69370120a6819ee9b68da652db11c7bd8668c6a2a4de0beaec9486dc63b6c2cc41d394cab5a9b97459bef77baa7a1df1c306b78ac61a481b5db0dbd32fb315dfdede0f9ca65eaa067b7f2fd0ffca1061ab48a2c2a01f55e9aaf3cab359f331765ccb4db7637e7e906056e01d39d7b82664534b55745ceae165b5ac2c3d25ace8d1dd6737f63cfd298899e431910f4b1d42d3407ce2c938edafdec4be20c6b53c2cb38464a84be30955fce3f19fa9d6e0cbda37a62aee15933d796aa953d2096a36eaf9e5792ab0290a8997ad3dfa857db2bb2b987e937f8ef366918cd82d8d1b5744f837f162b8002ba11bd6d2e71776ab64f4b803a6d4df507a21bb5657a4185cd7775608fc6e6af91db1434546bba555f05bbec1fcf46ffbd229b17320b6289f14ac879d19a968cdbb38faad5a2c66ac623de9630f87ca624efa44e3f12f2b4c6fb26e58541f672bf723f3a02246321a1e6b6643754a4c71a9793b5dab7b5e9314b688b76dc36ad2dcb75d4ee1134b8b79efeb93c2996eec12568fc2cd1087cb05e15df5f4568d88df6b0ccc3684b05004853431d09106ca712d9039081b127801caee9b2c4eeb5183a627bfa91e1b514907223e453489405c311a9de4c333cae56cc5a8b3650f9ec6abd2f4dfbb553e6b3fecf7ee40c7c0b14bd1c93dbecdebf0d7053fb9811583894e705b842051041c8f4b1061657e3aedcba7e39eb3ad83bb93cec6ddccc42d5b72e1697a62e124dd6eba810b036259149a4abf77435f46ac38642bde60c3b092172dba8206b5fac851928f3a714e69bc42775e27dbb079331dd33c3123de7473368b2d31547cdea75ad298e2cee6399afa08f4dcaa5b556775721e48e668c057c4cc7f454040d6c953c924c368f55732750474a7b8e55912aef9c17b3217a0bf0d0425bc508ed31c998eb96a1eea7096907d087d48586d7aa72ab6269a4051832b6396ee72fd82b65d9de59c3363c828197fe227149f7ba91558cae021a180c16e1683fc8a743c15ea3c7981fc25f7f24570e154fcece10566d707ba9e28c2102e7c534c8dbe1bdd2ed8a32d0039a51bda9c2ba5796e5227cd69431de38704bf7d60a9fda8cb19aa73514ee8f6d551b6570aa08ead9b9602ad8a6094c3063f66523d08350d1d4f477b0aa5bf6a1a322a31c1875a92d50fe766857ed4ae4954e153b90dc4e7db5961b57afbed9c6ed07cdaf958249084958d9f8547d6cbc518a1a846aa1bbad57824f7dd6a717926ba5440ce50df92efdfed36d7cccbc7e26057c0ae4635dc531b3ef63811d6b2c23dc4d9c0b6a0384cfada822bb8a7e7dbb43e76557b4af37a9793814554442c2b29d5374da4f6b6581910061a76b0b67f77444da5b7df8e15e85784d4f91a472d0cab12a3833fc36db35ef0e58e4502e652239b4a1071a86a60ccecb67f62ef6a05becfe093a7fb5ff6acceabfe37716705356d271413a15f349d567143374c2787d65ba15bf67846b6d46a73667a67c568a15be2eabf4a1fa4e9b8a13dbb82cc19ac9a19a1fc06eff4c497a362afef9e2b9661d1db6f798c7539648b5355a70bfdd750b0c3ff4ebf4aa3d3a4c117fc588d6e85e6befed9e06064711fd17fdea47aacabcd04a28fadbd2ee1b37c0cd8ed4c0c6b3edecba7d6c9304d59df74e9b5c9066527a7f9c4749347f58b2d8529bd9db191f79a6e3c42c6d8613ae00cb48b3ecfc5b54e328d89aa576144e24ed23dfcc6dbf584d028e4de54fc9357c478a582a2ba2b36713572815661f053547df5695c0d9b76e06b1c076f949732fb206141be51788e0b0b2a669072b259778b22e8f632e489ab45ae5e32db65e18ee72e338a24cda9a1a1c035292dadb79fbcd4ff67c049b5e8e2ec37120d56d33c5027bd315cb8c4765a37decaddd70e243bb85a502b4ebcda4100d6b8f5a37a0ec1a9da0e5d1ec1e26e0d340314f2d81f5f2bf72a269509a016c4cb75862e7ed39f71524beb97705d98eb9d644c9fa817056f669b22ec4e80a228dab4796060f830cc37dd22cf0bce03d9be9587a1ca6aaea4240086159decf3e3e6380932a17004f91dd3752e07fca6a5ca7b6f3151890454fd32d47750fd6c22b14653d5ee467d3b05148a7a70ed4aebf4ba95cc150d22682af628c68aef20c58f8aa0e709978688fc826abaf9fa0e14f4b7c55596c1a2d49dbf250510e8bc185f2ebc76c47a6667cd518eb672cd4a7bc29cbab0506bb6d7f9e7d2293b1cfd5b79995e786f2ebccf8f930f21f627d0c1613a399d66810e666a09177f38094ccdd3df4e1fa12549754714c6433839a5e7da537aac4fd0eb092978ded0ff21ffd6667d1ceb2b8251a10daee3dbb290e7700d4d0dd0095add5c2ec053ac07d36c5df22ef88284db118b170d1eb5acb9965ae181a878601f551cb7f3c8cae674e9f0417e8baaaddf40eccee06b956af0d7b7b961f52680dcb6a8bb8aaed06cd935e7491e164d9c45478bbf9dd4d3fd7f7d8e7f6e4912a6d370553bb63e99f3fd6f3e46a9cb0bb47f563390551addae3527959e416eac27d7b435af011df2d348cf47a09183224fa8c19fe42ad7353ea9c98871e612efd91c7884a8bb6801265895cac72e8adf67c2ea354735b214ae9f4134c1394f3f2fef45cc601b9f1f2bad0f0e82d43bd6e1f298e7dcb3d33541e33898f39201e9bfd297733ebe23a87df46aae238bd1923677150c8230c260761695259ee832698c19a1b7ca66e3161542c95363b396b3cbcef7b14e3c2bf9134bbafed3eec1aa6c62b7238326c2e45e9d5bcebc6fab626a5c9c13e2d36b83966015b3986e65711d8778ee27a29cdd33c541bc183c5dc70f7b51aa7b85e8497fc1711a332b90ef8d48c70bd2baa8854ec998ecb3f37ef56dea471a679ea81b1983827cf4a8367de0b830a1ee2de68cdd09fdd9cf768cb8675597d10fd01358dad91c65c5854b1c40b9b61c856301cced5b263fd0b02e1b0c8df46b6dfaee2896d2d880b396aaa8c390f321c4df265a11fdca653d30d1198a48d1090db64b761d5cd985285563f591ee330a78d9b50f03e86fe805054858d1e733f527d5e16a85e3d337dbccb47532f2bde88d0b833246febc1a0d8910a4b8d9af126c11d6dbce1e430d724c3e139edb946cdd6cdee8641b39d3e7eeb8c7fba15a89e757553c40efb93b1156638afda766bc668426bb576cf06f4d2b58d44f8ae8aacc6c216a421a3349a97c71ec294d2159b2777b891eb6969eebc78d606f2e2e1fb7ea0613d46ba6443d38fbb481bdec694dccdd273a8d48cc48b47e5e8336071974a4223096cda7e80ae69fa45fa3573288c65105f6a318d742e8f33cf09dbfcb566ff228c7a190c859ccba51febadfaa6b33d34ccc115b8df908c9336787fbc4857bea89f9f964f992cadef8467a3ad0c503d3a24261469a4d1ddfab106252b2886e7d41d7ed9a615d499cb8a9ac7782dc60617a6ad0d76f99edb6cc2cdb64db4a6a1c12db15733a9d89ae390aa6416f80acf7b4eccafdab3798a3c00ef0c0d061c05885dff3e2609f2c1e22f1cb137034b4bb8f734f6e1a11a7b6d4b5082b6bb6dcf7b6890739cb200de01c4a66c0399738576024134a952b8e1b8509f7a9ee474d06fbeb044d9b347ffd42cc3ece63dfea289fddc9e1309b73a547a9f96140c10fe2c2b261f02b43baf3691043c98998ebfc536e531b9d975f833d75a3b4caa4a12e0b2ff15830003434f26683a9f78616a6e86c13ebf7b3adf338b713859652e38598d2aaa45b36119a2340ad7b25a15abecb42b47af5a3f1146774bbde9bde3b77ff773aca0ea37aa499e69519dfefaffcbe83ad3336a7734e54be59ae9c2e6bd86e1f49f0a6e90c661c4402809c326e1f4af74e46d8af27ff10fc8c9f4c691e08e95746372c73fcf3a0d57f3514fc1cab1ad127d84baeca148edb44b32c94d69d9324f712daa97a167f08890a0d3cf8695b986396616e15965bc1a00e5e57f164f93eca2f47d8c79b5675a590b8db5a9a2c3e5f51f2d01dc556ba03194b5bb01c26c3bc42241abd82dff55ab4eec4dcb364d4efca535e460126e206f266f600b5d2e32106729777c6699b4ca0849df5cd1463bd51497317ab78944c0ac7043fad9d135f4999663b34041b3c17d965d2d87e72ca60ff29bcf4a532b3dc51f0c51272f5ffc6243bc7f36088a09dd9333e639bacf8fc4dda82181413aafe6da8ce8cde46d0954771abd8d44443de6e9339af924e6cdd750316faeae9eee001ca5b462ab986f3f1fb6a05e718a904db526daa7ff9e922da7bd1d4482da8a672e1ea572a1419e80ce51dd3cf0a52f545bf6b936401dfd0645b75633b5466fddd255d2358b548f5bdb5bd7761c5d07558c63503e1a7b8e83661b0463d4308522a9bfc0ac753f5dab8c037d6e43caac06bd7e72defef075db4a22168fe9d4a30c31fe1c7430d75cc37df2e457c65f3072deeba5e7ce6552219a5c71bcaac8b3544906a4e72801a38f79afc1b2c39240f4089889a5f02442bedb1afd779c0c95f269e5a2472843d3a4e3020af8925fd629e5b2f91bff7fc4df300db7dd0b48bb357c2b57e375cc135f90823437664cd776b59b50a6a4241a6d4d03ad17fda1ee139ad3ad55ddd1bee6afe2b22f5d73e8563ec9a25e361b0e2ca72601b756f33fcfbbe193f7f0291932874f8c17532ec359f77a84d54566df3647046060d9b60885ac034ec65b298312cba09e0fb57bbd12f468b5cebf0205c1626f464e7d3e580677cd95c6f3a658999880a1970dc2390f627735c6276918f937d6a17da8863d116df6512a23af33b9cab94a200005fe3660fda610ec1f28a9ff010000ffff8c9dcd8a65b911845f65a8fd80943f4a65bf8af166dc8d1983c71b2f0cc6ef6e42e7b67145a8a85a0d344577cd3de74af913f1c5d3d222c54f6e1524f09982b4881a75c6a5c843e107911bce019e7c58c610c91168e3401148e4fbeed6fcee0b7ae7a74a41e455850c4c993947f560daec8d9c767458329a0589b983fbdabd1c36051e2bd8e4a1564e20a205a685f9d7e495b1efd5651a0d08799b7f464ffdc8a380bd4f8ee467681b9934520515680e2c36a886895e705ecb32e58d6be839452d8be80d136abed7b88055a18b90d5681cace0160bcd5c2e22928a40912aabfe1eb552b77540f74b487d4d384ec42ec5548e5f6fbc96975fa5b75e2d29b8f23bc7f6e84fb9701c8dec0139d8e5befb600bfaeb47d855e4ec02dcc5e74cee143b34545229a34c385830e50a69f1fdbd88f339d70239ed5265c011194b7e8ddd56bc41987682d295933418f90dece3966678244ce93c54ef40052c93f680ff913dcde7de11ff79e6926f7ae3939c261ba34e717d60b5856c0f2168ccdce2f23b703a6e377afbba385b21939161594f37a9ebe126516d0350a7eb725a38b2af457578c342177e6e7f72e0bfca14eb9d8a70c8e0c236a17f0f49520ecde841a6c0f4f80c2171f5eb3e2deb4a844fd26283134b1ff5d2497d11328a570bedb550e85063745bf7d8e6a90b344b26a4590f285c1995e2ad395b633960815cbae2a6491e559ec8942d2fc5744870f8e96dc4b5a83d669eaa9809c0f33c02f19cac9e42eb3e493692fad38180209159238a43074ad618e4b3e2716111ca9f99b4fd4ff19765cc01d6eeea0c2e60f061374ef60c8e42f9806a5a59cb6544c286a8a7c28275c7b93c497c93b93783144a581a2849b372729cce36783be529544b3ec8842460bb7cb9763b6b389844f8a8ff3b6cb2a7cfd7d4a0067c5ac676cdebce31272ec5e0357f740e9d3382784f6211244ff4e4b2f3ba609e632342963d0d58e8bfbff8a0c750a13f04e9c957c9dd597bbfa6c7d1192e15d6b4d001676e77e7efac6362cff9b5101e5886607f2e127cccf816bb08f1aa6e7efec8c316e24d202d98f773e38487d133ad915e9271b59de93896188489c87e2cd09144d8b5101dd62243eb44d94fbfd6052b2ce5e395eff52253b7ce4bf0f2ebb402cca94fb1be2fbd5d5b4ea5dfa23f49f68af6da1ca972d70fa165f1e48cb384b79c6f31085aa6a47ea2f5ed2e49a54f5cd0a2b471683745e6921b572c2fee363a4d496686398b89bae14893e09f24eaea730658ee0e1a74034762f2b9a01564192086a1526a1f02f158d652f01fe603bddcd76ff19599803d43ec5074d6129a3da6bf31f5601e2985e3814b88f5e1bc190a1443aa8c4b3debdc0f9fc46fa176400f63ef4179f7fdd4932612901ab07a760f972ef94a55bbeaaa2f7b8d5735512b199424f5dc87f49cbb4051b070af2cf412fe8d7ac65e31dec5b166131180a2b468df5b2cc8589beed67efcc26db704d187ad1bb5571b4b8a1c3fca197468d30603127283352759c79751a2a11f29896938615ab2910f60bc9b57207356b4fc0f5c68dbb74c114480efa5a2cf7255802794e6f2508f359a4372907fc48d31e0a435595463c74c2f2b859b130fa641a4ccf2770067ab7a552c730923ffb88a667c01a8320d6f81a082304e578f6b948730d3c702e395fb44ab29e8ff81c71d72471f93a44b0acd40ff38856d6987cff9a5540260f97ba46c481b302e9141c0a9a9291827174eb68186883eee8e461d6f35ffdfe584db99e554f1c4a57e65e5d158ff4ae8c6c1a6f346121e0d999c6c5cf70c2d4736b064593594d9f515c9794e4f53f8ea8ac98b43046a46b29a333076640b2b66e29caa6be3e2cfba0ae61175d15c1521bccd47b129d77af27015922701edbed6fc9325c5c0938ac435e0fb6390bb01b2261e15dd865e33443e84b6fb3c27da27faa4576877fac5deb71069c72f957b9b09f9fc0a9796d89dd7cd4152edd77ed04b0e5445873e861a0ca85bbe6fd0d17fea137d066798bd97d495e50a91095fac323534c17c60b084fe855d47fc288bf831ce0f86e0e0db1bfc9ba2393ca45b99fc15833ac732049db6823643565b10da8d21910e70839a4fde0f76aa4d09dacdfcc4c3ff947a508517abb1d433f102f50ee1c44ee4ff4a1c1e7681bdf960a379e86b993c8157a09ba723a7a8c79189b8f3324e4418262f8eb38728c22ff4444723b784c867f3bc6d7ccd42e8caa6b4404212eba42538e94345e3806f27859107a6f290841e389f9cef588c81b09116ad69494a20ceec4619c8e71b580a1c20e173731b069afefb29ebaba8c199cd4a4b8b9229addb86dd510e5d205c655fda5e12b5153061e8ae0eb6485675dbc2d88ff3cdc1c2488de0454a105fef1a2bf9c8b86632a3de31135ee2e085e554665cb7a8b58f406b73fa5c924f8d2151485c4b8526a5dc7e5790dc45b086a83366dadda34eae6a1fc32c92f342d32b8311ae77dae6391da385a6218b8a0f7f03e8b55061499507749a7c0d8017dd22a49fb50e47ef2b30a88941a10cedefe3f9d91309975c28616daf3ab7ab9c1d1ac948c9b678cf937a651fd90586ec3357b0dd0e7ed129c10c0ae67801216208a138103bb3d4cd5f39599a82678e2ecb3e5b46bfd2bb3bd8e6b4721b8b485e838afc8aead8c0da62ca6c8dd5c226c2ca4362d2913d1f97e272458c8b7a60f6d9f54b03ba5a22d0519fa292e6720139f422c80216bbf4a4467fd39f0cd89ff972ae560315c238a668976f338c09acd98a8b4b7423e944d1c251e234c58070c86ef6162e84bb3534e5233d4c30eaf0f422579c9fdb7a9294b8e727fadd3391850b40fd15e5bc13825947c68657dbffc94692577906c748ccd15094f5a7afd153b5c15a370492b42cc55c8e7dfb92de654e90927470c8f90e2f042dfc7132638ce6bb7120f5cc34cb16846ad143812929f9f60addf8c9415b52365c5552b8b1d614f0f71efc876032c2a429cd222a99357944c58af2eb07f3fc05b1f022899a53bcba86aba2e4c908aeecf8be6c499c09ea261858c5327f5362da384e042e6891d1a5f5dc40e5966682aee3e7f8d4af55efc58ccfa7dbdb58818e4592845aa0a2de9e22c72411cbd383ae259bb80f062ed1b38a492d60965932c25cebb49722001a14dd5b9e542f7e97019d6c8512cf12bfd5d5ac8a1947858b8713676d4852254c29d55c703b3e700e31bba2b350ab6a1f75c2711433b61be2143ad4b0ea625842c421700b9f0a3c573e6954dcfcc8c2b056e41981d47b5743f8238fa77fea29ad7bee56ba8eef6a61ea1249eaa5a2837340487bb85e9d3d0242327aecf7ba931e98ef4bec37f2d7c6585fd2525ef38561e690c54b9ea342b40ab674147e8348dcd7a2700f793199969bbe976b738a88069ae051a253006cc295bfbb97d6ea3153662cd3325b319ab0488e2d8a95da186a498575d2f83e416e3c1f8b25671bffcccf52c50c3c677201925beb83882970127031f398aa61c5dbaf3ffbf3f9ef7f9e1ff9f7eb0fdffef1dbdf7efce59f6fdf7e79fbf1f7df7e7cfffefb1f7f7dfbf9b7bcfdfec7f71fff7afbf6cbffdee1fffba16fbffc89b7444be7e228949d53d6217243ec006b891caa31163a4f1024c4e4e93136f7568175c6a68760b6e0c96d19482444725b571732d45968cd42908df043ce2109129d3e64686d0023b1d7e5b82cb7b1a7bbb184e521e206f5905768e0609b04f320e1ce9a0164b9f0b567ba15d6b012b57eff5b217c61633582c264033a6b2d2fd9b61a3cec12b7b68728740c32c3c1d6cdc077546212c0e8d9ec0d044040681727ec830af7b44e4da703536e0dbe23564f28c3e556878597e7a855c37534ea487fdeb25a851c24641a906bb1c838f22470c8640d4e4a160c2edc735c1b3922a9398f620b43c40aea71897b3aae60decb1a886ecd219be925593110afb23663ee9ea4ba41f0ee90adfcde3539611390bbc9c642c8632038e1d1979d65a08cced660fa16d24853324aca7169f1899f6b4f9d1700a0c69eb0d1354b023247ec6592b45c9eb2c2a8899e813ee6de08d0a5c754817c4ba63462d821bc1ba0949b054e08ed0c3102837e69d2e663dbc8830ad8578bcf39eb08dec0ace3886f86cf65a91109a7ac8c1eec3883a72e254cf68d486755ce8023144c50e52b20931579dfda7b711910672cc57d0236e7028bc75b9d4ad7dd00f0493d8f982ee1e76c583d14e80553bf1cd4585bf231b1a009e013e588fb044d04879a6b04116a34e1751c9ab495e667bb6de9cd0c10176954d020937edcd3970c590774f1293865684504ba3b6b205345036131b02e268e401bc4899d4b4b3c9b27db8dd7d1e8ea84023877a573e9efb01008eee724d068c37a0ed1cba8470481c040869278f7948d9143ad31054f3952a21030611ec53bd39d96eb92a9b487cc8d510e0f79822799cac608d55994c0fa4042d1c4f8d8679b4aff9a07ac6ffc8b6d6c13196d1b0b57b95637dd2d595c07f6267e3818cc824fae440435e7604d8855f5de59e742e3151dc4873b783f8219540920782e16abdab324565abd6df7a978c73492f02e30dd790f8465244773eb65f2faa812045521b362e4dbad1b0004cef0c815bde928d16bfb9676c9e054e294dac38ee669cc938b23d212e8a29788e04f0b27d4c7bdad7c88b265d5de4b0340301422ae44b58f6071076ec496a4dfa7ac00ab93bcee8e0c5d9349e80ec1ebc321b0052de7712034dcdfb60f930c637c4d79f40d42fe9068bd34b43582d785bc955b20483b8a2742f017b8daa2d036af66b01b60be7c5fd76a61852183f0c24a85b48215bb72253dade1d821f264fcee5ba48d81777348c100393e8fe4f3306148380f0c068dce1c5b3b411f9bb7a38aa65ff5d08e2e4cd2f769120f968eb0624fa010b4449ce13875030505e3145a3a8c0fc6b8c063950da1e478e9d023717bb25a02f85b01e6ef34266102beeb12e13cf606665a3cd43ef7104cc1d86653df5f844e49883444d0c9c4658b031a50ecf4f168f26cbbb267a910a77aa8f2cbb69009cf18bc65380e3d63e832816fcf63f2700cac4bb44c51c533a65cef7583af9ab9a0fda77768c2d12323c1634de6b7002115746ac7058c8cbe7e26abcecdd16eca4b54703633053e7190726f87d807ddb137e0d41273661b3613febac150c1d9ea71d2ea84f00d9da2d838214c31ddc6c0fe22b3893583916fe5d0e0fc170000ffff8c9ddd8a65c79584eff5144ddf17e4ca9f95997a1733c8a831c3489619f5c0c0e07737dfdebb604e4496abaf0c8da556d5d967e7ca58115fe8c93ffbeb92ec1674eb98b55889d1de6607ad91b519eda35350b50da376107c4837f84f5a2f6c9c3dd78b87d0308cc57db62cb95ec5c0c77fb1bc7cae5592e9ea1018bbe894d41a9d44a2b8a14897a27aee244ca104ec5ccc320672b27718103bcbb881896cb6aa661f3bf6765d8568aa65a3711898e0586a3aec3b8ca150e6eef061edae86e8690e07b263e6e5c4a16ce14342d4e868aa5641fe358b28e88c6d416ed205d65e586396a50bf8c008573d387311d0348d93d53497ab9e5a7d1fd427a6be6c50f14c61766df2fe6d317ceb03783845c9c2b456c2d1217b66d70718ebb61b23f6b60915d178b6610e131a64b7c52a2733aefe604388281f3e9a57edb3468f587d6f93170021a5f64b8fba76777a34fdf07d68b7c4655fb7031308e0b0daf2194edd27c7f08afbbedf418b759b2e784bb384468fd1626fc3e570fb507516bec7d6f96aa232ca62eb780862b5b2f85d9ff098a75d601b5282a5697ad3ca967263bf4c4720875d0c3e96b8e386da54a9adb771ca07dc77a3b2834f2ac621d7696740bdb7b860c79aae399fcd8b477e88050964995d1b4655d120325f6f35b4b2e6e8c339674ca9aa0635b6ce826969fc06443ecc3246aae8136e90bc64ff6d20fdce0d7a9a9053765653cd8e6fe24b1135c904aa7730a4997e9923aacda34104501e8e984069432b768774ce9e5f1a97fc58739b805e39b8d6344ecde194224f542cc85c66ac6156a4b95cc20dc001b63d1a1307b91a693ae267586e28cca486194b3762bdf7da97f6315432bba141eaa8a402754f87b5d30d30d957eafa6462bed55505ba44d321632cbe58ea0dcd65c2e9b8ec24fa76bf33d366dfe9c47bf4c11cbdad25ef7c5c8a0690aa33ebf02a757ecdfd509b386a2d9610ba2ab876dd3690134d34ae148cb1690cc2683afbefdaad07a06d7c86fa00d67077ec71d5f56c2feda76a7b287762ec248b6f73cbd5d4a954280a7cf46575d0092a7b36fda634aa80971c79490782214906e416a5e61249b4f7efd8d7425265a2b2e65498dc459e79edbfba9fab4b4bcd1fd188f9ebb619fc1a5cc6a8568210a908ef36c95058cd786222487da8c26fd9b02034c2d1d191359279bc0c950ee444c51fe80c61eb0030501621df0e351a1cd8dddff414a42a330056bbb5075c8d571ab6ba52d1a95794553dd48bbabedcde796f158d8f056acd98231b4a4f5115775fad8b1e8a6d29efb5d1317c6bbc70536dec2d43fcf3d6241d7d24bc7447b8e168d37fc7c21a6845656c139408c1b1fa8a82bd5e174071c2c4a62b9f6fc2ecddb32d1ff9b8c61ecd01356c7d43e5521c7cf393b5d63dbafbd056d79ccdd8b29d25683543dbc5f0aece331be02e6d85142beca16fb45c5b4a7106479c655fdd3450210c4c0d07cdce6b4f0757b449eb736b6d4db7e75fe7a395384da085cb17bc35f57740fa62b7437d7d5916e4beeae0967ba5afd1c75ee6621bbaa5b9ce916ef911febc79d8744e7781af569a89deb12ef3ae2e08c2103af3b2fda75ae345f1bcedf6c0017dc3dd686a5340da0013e0319162a5675766c19273a72f27079c01e3afd64739a0c01216d38c111bba129cb0e56b17044bb6a9ef009bc41f63afe05f3ede37f33a9eafdb8577aa61f55ee0d581c8e8296fe7c987aab7efd4ee596c9ba10271ace9cb853c4c73eec16ce4a296ed6272d896ac66cb6d14fb462e5cbf2e8d58be92b17a8ea6ce568aae57d52f2689b330fe0cde2afdf97bd9b10d9402d3dc427740c8f089a44155701aea98707c415f5ba24c17c341dbae4f16c6b7a7088b85feb621cd5b569a11abc8d7658c5e5e371cf7a001a6711bd2a4775dd26520161839a2f7f9da3b7aab1810f64ca0c7cba1e248f4b9bb9b8677ae5eb47a873ce07481f8f5dd7a9b49a6bd9b63e03868be6f6729a5af7cfdca3f12ed9cf66b59f8bb0d4ed6498fdaf0a32f97f7d8e2ccb5d59544798d6d5941bb853765ad619d567454ccb0f975efccb07b1d9d56d5d672366d5e372ddee361df390ee930f7c86a7b2b23ba51eed035b793d301ec0157de5a1400db98241fbbc69af673e520dde7beedd1cc3d44a9a34e1e7a90debbca59c701f096c53b39275fb9edf591a70d266854e011f63bc82bd0695cca83e7cd3fdc5bb8da74c11a71f4127535361fe5d577fd90a0b3183bcc3e9b7f336f3364f8494b8c26cc258295a1d9130e51abe99340a6620fa7f6233617f510011b4fa5dbc3caf0101c95618a22ad1b4146bd2fad5a2ece1d1ef7f576a8dfb761a3b6517190ed5e4a0ea70b509f43e233fa978f4de18f1a3658a0757131c4827d228774b015498346056c6b19b3fa850e517b29078faefaceb39f0f5f8fbdb67687edb71d6b96f9234bf4814d4881d757e996e5a7cf0e4d2e67b35b6f9edef1efc8fe7af5e7df937aa778ac7f2247bf1d2f461fee7a30f7fbc29745e055a6a09e03bc9cf68a3a24172e92c2544181d87259c36b17929e0e2f48e8f3755a7b20057d4dddc5373a7ed3cad7bd14fa323e8e433892a7697623481fe695ca8e5db55e8eeb116a05e4b31956c54af371d78b6f2bbb0f2df46532b052c7cee6b4faebe92e11b25405a6433740ed358a470df4def534c3c0cad07f6f42445807247b7643b2bbd6703f38c4a2d5335f136e50fda1f104fb813ad1711ca95d0cc5adcea5e744bb9a20edab8389c74ecbe8b59b5f976d4c5f87f6ab3a2d610ac96d847983db8e5719e5e9eae5d3fcd41d16bb43b555d255e4b06516ea47d1cbefd91c96f5e2ffca1fde65e6fa184df0a1b69fd9af819507f75fabadf820284f7d7bb5eb6068a6ce73cddaf6147255d3c11d149ee1c726f5745a0745adc836661ef57e5ecdb6570db3838eb0f3a30580f762bd26987da656cbf5d1ab898e076bc0fd0d1f6dd95d3936f74ab9fc642fdb2ae750d1ae4a5b07debdfa671f540587a0ca863179e22d8e8cba36edd04d0a51ed160fe14fa1c1980fd1877ec83d88a0686028081ea3d9ae68c536e81ad562b39be675f28e15ca93229605152edc908389d44ffeb013ea300ba729b5b7629f69c6cab8506aaa1d224b370b89f202465c376711ed5afe068e5d6ce70462751ba2f532d0db5144f8d4fcdc0d9898aea7c008f9379576b29906d628bb861bf03b528b9d438b1e00ed583d89adcc4ec515d449de5aafce73b248fc212b2ebbfb43bb0915c156f43371b1a55de86155e83b948250dbfc36ecc5e216391a431a8d97c5aa3eda081e71edf4d9a316abb901f74c6ec3eef93943c614e49416c3cc1ead42969b3fb0652b2da944766a760c6f7239065561b6c788ae3714fada2dbbb2dd94c0dd3053fbf35a9fa9f71be8b3562a02a0652aa60b7cdd5ca6c3b295f5b7385918534aa0e62f6f9ac5c7e52f40526936b2b7c03a6a430e5fd22128ec3e7a5fa9666ba66dbebc3ac9773adf9dbc32d6eb72f111316616674fb55594ad013337d2cea205e24acbdd5bd665a69fd6f10178046b1042b00c181e1903038d0520d7ca7326007b732b92c4ced41916bbad32b116bca3a23a01eb130d2e9ec6f09a4117bdedc536f8508ddab02d0bebdce59654a64ffd8d3e658db49cb6cc671726b00d6386b803ee4ebe1eb038880538544db6aaa8bf365390159a5df3149725290cb0afbeac67bd366d41387296d72dd63b18d32806b4af6ccbbab2ca6479ab7ee03d2c0f5757b5110c1a9ea52cc113ebc8cfaba7d8a5988ccfd6c7b02222d862878663abe84a2a4bec00402a572906803ff739533c3005999d18f296d6d7678de247dbd134ca5ea115cf6ab7b40bca59a765131c46f973b3cb731063e0b1e72d4b76a71099e8f18eb5ae75eafb2ff3e274e8209da316030a6f7adc4c8e21833a75774abf7c4be3b8015aa93a7c8d55952bd606460abf0c1c94dad6a1686aa34ac37e602740d26fa0324a824a33958a51b9762f476f96b7291d55a0195ba977cbb016946a3d157a595c6b756dc65d6299618cad8d9a7fe1e46437ae5bae57e3e5473b463efed404072e94b9b6a71ae4b9b8d32ed58a4ad0098c017af517a526f98fc48b606608cd9e2e5a3a94f24c1582ae5b06dd6e8af702db9c562884aa0330d41ef6c67a4aeda5e0818b11b58b85c0a1461bafbe0faa6b5424ad45a5dbba12f14603d07be2a5d579ab264d0c2a3dccf5ca04bddfce99bef49cd90d813f32ddb24c59a51b84cb9e17a141ac4d240815e23219a996ed1c774f5f4f826258f69fcaf62515f324c68bfb02cb8c62865d2a7dea364678b2e50883abf543301ca23baf72d53e122940eb662b3c065d604ce46f9357ebf4490bba5b31ff4465f59e1a8319b8c9b511e5ca4e6fab49a442c968caa311e39361a0edf99a987fe87fbbebe1728c05304d5ce163fd669d182311b51f7a07516cbda50284ab596f83ba83aa43ac25dbef953c462a9746697f6c0699c836b461d0d1cb1f8dec58b4b717291f9c1247a249ed6d7b3b31393ea879fa6cec79851054e02ec3803897f77899956c5081a3be82064ab46aea081caae15f1ca8e333e1c72bea8cf62a78be1dd3b3f7edb7b99ff6ec303cbbc21b843f6bb188cdc2cae2a41d7f9037a5273d8b160adee674a690bc379f9be89233c644dd73b4612f740e78750ac79831d40986906bac5d2004705574465b3b2c515a91a7cce445bdd3ae4685a2d164fa6dbf331eeb44136b0ff36ca3fbd7f0a464c31d24a73a4e1e93b99f349071517ace3497615daf5bcafba5ce066675ab823d1cc2e53259792a0c644d68b9285aff6adddbd2c25a84dbe508d2962e0f9bba77f141f1eca645aee8d87369b03498566c9bc9ad29f4acaa2c95fdc3a5cb159c8fc20c28abd3ef3e3a9fa9cb50702cb85317714bc561f5d848e436c65f44227d7fb310c848876745db66b442d0d3ef53df6b991cd57337f344b94bfb1e0e260624ad518eee1765eea9b4bba93b05a496b853d6f2da6a1cc3afe5a0efdcf8599b4e3d17a142af0cdce8ab594080ecd81a4b2319b7e1f7b8ce9d49ad845af51884f4eb597bb3ff7e66ebb6348cdcc65a7aa8afb6a8a0975ff4284571e2cce15d737a6c47daf2c5da580acb21e1b3544eb59be1fd40a169e9c7ec0b9aebbb7ed5dc9bec4945b3d10c0b28bc72f0650ee7ee8edcd5b23f2eaadc2a450cd589d98b946a572e144bdba4e7aeb1b7d60595d92c2b482fcfd0f6b464b64cd3ea5aa415f55924f2bc9d7f7c343c5516316ff84a455f30bfca736da2a2c1c21774f0545b98188de0d13ca72365804414cdb505df612bbf583176e9ee6ed7dfe2b3f6edad19040f525a51df16c0c269b84f724d8742ab0fb2225427a050aa51bcd5b1ed3e814554c31a83914b6788c45979e83f4bbf7e01a5d84d6b6fe82c54a755a35dcfeafd7a8c503b7dec5475f1f4ce29930731d44b972bdc333cd9a6cbcfde93cd861ba9e9c835b2aa6ecd1e71b9ccd7c6bff7d628b57052a1d38ca9ddc752666d85635bbbd6dd5f6055e525125736019269159784296219b51ca01edde7890140c68663d275e9b0703c6acdb460fc331abe88bac75e46553889e6b8be6a330d821e1f533b8f742a64d5618e06649569967c5adcbb6b2b594bb3ed2d656f7641a9fb15b6f0a895689087aa35e918bd3f86d158629bf82e67ead904736fa1b8fb6adbdb5a6deaaa9892c93c70b7c670340ad5e6c59ae7e1b8e8025da15b6fcf65267aa81d2eebc4f365cba67695fca8e052c7566b40e8da36067fbb29a3d9d7c1ad4903c550632b7d13535768487bdadad411e6fd86d8a1266bdf3b07cc52c82797ecee4001cda2debb58eaae6dc9833bce52872d29aa53de7ad2ca6856632698fda915e5b9368e7cd5429f350995119a8c02bfa0913b327bd56866b3fa0fd63a9288e6370a9b600bd4065d69d09355379c438f745a388652cae01464e966550a1333398cba7d32b6d37f580964aa6dd8e288d3fed7aca9570d1add7a687c7e507ba09b909854a984c5dfa9611c2aa65e3d1e6deade843e9dd6b52f9a26f46ab9183eecea371bfccddacda8f8f07bbd3c86070159226ff34d6ee2004acde47e3a4ab53604883a5676d48badcda990ce9421873b483734a3b2e2de4edb843bd8443cd264961e7b6eab7739adeef04b377702651b265bc71a6318278a4e10d3ad4f9b1f5270283d72e759fd0445806ea3b08ef35a5c17bb37656a0e05fc781ef909aa2c8b609555c812848694c3d611c017cbb654d15c38646597c5dcfbf2b73b10e0e9e31b208f3e5b05de378dce3ac622a71cc4697d4c3bd325c5bd52cf067b229eb6f761cd550bb9de541cec9e3a22d34f32b5dacdc899f7340d005c13deb495cd61a00166747bfbd6dcd50ed66d5a3ff2f71ccd80b9a338a472a18d69e954f0cf2bc9aa3622f9aa91f60e7ad8b2150f0ecda6e1c30f1b8d63c9daa1a8d7b5d5df47e97ff8d391919f1e0247f2e13b3ebb294463a8038d0a8b960641b7dcf943cda2b0d58a07197bc20fb1b5ac2ff40cd1699d69d852bfb31673dbd67d85bacc5a37732de592ae365fdfb74f9f8d199f2ca373af9941b85603b89c5c8fece8e7e17087f7b255eaee6bb7357d3f32b67e8a15fcb4d972bdd9e1dda71527eb27ae9566eba86606825a666d4dcffcda66341fe696bba9ce0e10c2efbe253cbdf42ef6d776be3cf6cca6a56c2743fa911d803fd3124d748fa7399450ca979ac6cf8b5a5ee6bdfb3887d759f16d06797e6f4aa39b2ebcbf66b27fb4cbc278354fbd9d348fb7637fcd3d78254a9db6f3c5de5a9ed077f1ae7738a884643f3fb89dddf2f8d9c6e1e46db107eb0d790c2a4b98619b3eb664eab23773f993eaeebab63add4063749ac90d6d3076590782381e7f8bf2b44b210baf36b7d4d007c8f44360e27e4dd2a16cb2c7293a7b061442c6e9d6bf73f22260ce5cd54ce727d64d10e51bba83e6fe80d2a5ea5992b31a9f30d4eee7f892562dff8c53cd249285776b7ff2aafe284582c1b51a83ae15c864d5731d5776405f7d300b544a21c3b263ff003fb5924d31383bafe9bd4d3aa2ac42fb17bc2ee8be19d4b9a74a2148c2cd91b882a3fc909dd9af4bf3f63803ad6cbacc99abba9c0394a7d4ed2b77c243f2c4f4de10854ddac69563b3f009784b23c0d67e9923542792821bd522faab9ff91e36f6aba7ed998d42273ed385cf4bf15b4718f540160b7a80bae3ed9c12d3affa3e6bf7197d8ee11cfaa3fe59af9576f13055cee9bbea3ecbd40bcaee7dd9a18615798ce26a026265da4ea9e48c6d4b319a4a9b39d43a1b4c6dc9aad9774b6b1389916359814fe4a177f28472be6cbaebd0540fd603a15dde898af07c2855cb5b2bae7a8ab9bcac73d632a31f2a831cbac9a13deab2fd1e70071df3482af87bed5e05ab2def6ec31303e091265376ef64056c6f7742d6d52bc1654afba9c66c8d91d935d459bb6f6638c980c3eb4656fd774f10858e273bf4e4867d5bd44173ebec9375dbc5f5dccd832f9824b371913736016d62e0d0523def748de174237aa4e7e389121b9a107882c5c9476b83759be1e0d21c7e3a607f835f639e08ca13dabfe74d3d5c811136d45eb25b5523370bae62151304bebd60c93ae21eba477f5d167c987d8abe572fb640d0f0d74db682eca183c32167c1dd6cdbd6ce029a7749c42a61d7284343dcc6b14b55d609a778e0b96e76c43a90c65e4d8dadadd3faf7491aec96aee335567e43676ab662d249341b99400cd9028db6ddd1d4438eb5ac5abf238e1e5de8d818f6f6f1d86c88e1b70f7d514148d1c4f377a344fe90c90ff93ead35eb849c69bd2e5b18fa0ee8638ec8852fee7329b145c5f3eb59c708af66ea3b97b3cdaeaa1d0fd79778f7653083d82c2d35135933dd238ea9aada0769bd78ef1578b8c7cd461c7d6adf2a2bdba9f531951d927e06b02e9a932b3bfdba0aadefe0ae34e1404748f70b417fd5559f3e6bf072aac09a83acecdda6038ae6ca572ef9b3e08e5790cfc7f923bc3945ed894ea3bc2f85b30e3b338e0ae6c55ca9afe6b447cf099b11791166f1c5c6e95ad6eb5c4d7b878f28987e31922c8646859485c4afc64d9d32f02ef8f31693b6f1618bd78341fdbdc95263c0f8cbf5055f7b196ea58a56b70b426bacdd75aeebb9338a1a2a7340d9d1267994b2a949e4a82ca9359e6c74e70fc82a37f5a0d5da6d208f365a9b69f7c8a325103bbbf2c4df432ffaa9015b34ffa775193e98b9757848ade3f6f6a7c0b4f1e1704e827a4291e48edc4ce62857fdd2f891bb64f6fdda5ef52cc68b7f756ce47bfb7016ae4430cd10461265e8b870080ed1d0526c340b34acad277ef6b0d4dba4e1de1edb3d2f3559b18663d960024ea26d1768e7c831cd712057ec07c6b5a824fadc05eac4d58f0f9572317e42e998c7fdefb52dead3be0b1607ba1d5d7b18d388cef565ab3d5ecbddfeaafe5a4bf60eda1b61c04d6adbabe5d43e684e5c2dbca908fe72b3a42616ed30e2e2b15b8d5fe3d56fa06f1f7a79e439ce158e243e167e43199bbb59b9383582866aea73f81d3a1bcc747994b35d2938797d365610e9483512884be37e070f9c4f12cfbb2f234d82aa57d1a3de6bf9e286262b0faffbb7630cf543efab971c5cbcbd65801ac2c9bc297f4017e278af558f1b5f463e40d83d6d9a4b1c0ad6f0471781c68416771ab3cdb2392e46aea089d68b418f6f49eb8e7887028c692f2fd0e0d5bcf69707dca09383aa09a5edae550f85a5350129e8dc3146bc6ac48fec027730fc9530aa91a85aafac0bce809ce7cffe72fdef3f7ffaf2e52ffcdfbefefec7afdf7efbfaf397afdfbffdeff7b76fbffff5dbafbffee7dffff6d6defefcfd97df7efb7afd9ffee7cf5ffef6edebcf5ffeeffa87bffee3bffff8fd1fdfffe3fb1ffff5edef7f7efdf9cbf3887dfdfec7f75f7efb7f7ffc137fd13f7ffa17000000ffff03000bd36c77e4060200" + ], + "rawHeaders": { + "access-control-allow-origin": "*", + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96086b125e60e5d8-NRT", + "connection": "close", + "content-encoding": "gzip", + "content-type": "application/json", + "date": "Thu, 17 Jul 2025 08:37:36 GMT", + "openai-model": "text-embedding-3-small", + "openai-processing-ms": "183", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "via": "envoy-router-544967c6c5-x2xbp", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "186", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "5000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "4999992", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_b9351ce5f84420f8e6078a467cfb19db" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-adds-genai-conventions.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-adds-genai-conventions.json new file mode 100644 index 0000000000..8ca9d3353e --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-adds-genai-conventions.json @@ -0,0 +1,43 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDJt3XpbTrkrYBUooP67fAFPTDDa\",\"object\":\"chat.completion.chunk\",\"created\":1752737269,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDJt3XpbTrkrYBUooP67fAFPTDDa\",\"object\":\"chat.completion.chunk\",\"created\":1752737269,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Atlantic\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDJt3XpbTrkrYBUooP67fAFPTDDa\",\"object\":\"chat.completion.chunk\",\"created\":1752737269,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDJt3XpbTrkrYBUooP67fAFPTDDa\",\"object\":\"chat.completion.chunk\",\"created\":1752737269,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDJt3XpbTrkrYBUooP67fAFPTDDa\",\"object\":\"chat.completion.chunk\",\"created\":1752737269,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "960804db6f65fcc0-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 07:27:49 GMT", + "openai-processing-ms": "172", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "176", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999982", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_24119fc6cbdf89dfadb4c34bd78fad3b" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-misbehave-with-double-iteration.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-misbehave-with-double-iteration.json new file mode 100644 index 0000000000..ba08b95c4e --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-misbehave-with-double-iteration.json @@ -0,0 +1,43 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuEAdgA3DwPSOefWR7otezsJxzkO1\",\"object\":\"chat.completion.chunk\",\"created\":1752740539,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEAdgA3DwPSOefWR7otezsJxzkO1\",\"object\":\"chat.completion.chunk\",\"created\":1752740539,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"South\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEAdgA3DwPSOefWR7otezsJxzkO1\",\"object\":\"chat.completion.chunk\",\"created\":1752740539,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Atlantic\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEAdgA3DwPSOefWR7otezsJxzkO1\",\"object\":\"chat.completion.chunk\",\"created\":1752740539,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEAdgA3DwPSOefWR7otezsJxzkO1\",\"object\":\"chat.completion.chunk\",\"created\":1752740539,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEAdgA3DwPSOefWR7otezsJxzkO1\",\"object\":\"chat.completion.chunk\",\"created\":1752740539,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "960854afac96ec0e-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:22:19 GMT", + "openai-processing-ms": "295", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "299", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999982", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_d40509d1b6b092e3978ea52b8c02864e" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-prevent-usage-of-abort.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-prevent-usage-of-abort.json new file mode 100644 index 0000000000..90efb3eb11 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-prevent-usage-of-abort.json @@ -0,0 +1,43 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuEFi0mRPm9cbvVmysG9jAHfivvUt\",\"object\":\"chat.completion.chunk\",\"created\":1752740854,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEFi0mRPm9cbvVmysG9jAHfivvUt\",\"object\":\"chat.completion.chunk\",\"created\":1752740854,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Atlantic\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEFi0mRPm9cbvVmysG9jAHfivvUt\",\"object\":\"chat.completion.chunk\",\"created\":1752740854,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEFi0mRPm9cbvVmysG9jAHfivvUt\",\"object\":\"chat.completion.chunk\",\"created\":1752740854,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEFi0mRPm9cbvVmysG9jAHfivvUt\",\"object\":\"chat.completion.chunk\",\"created\":1752740854,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96085c60cb99e392-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:27:34 GMT", + "openai-processing-ms": "234", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "239", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999983", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_52a5b54049b36409e582504ea7a435a9" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-prevent-usage-of-tee.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-prevent-usage-of-tee.json new file mode 100644 index 0000000000..35498bac62 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-does-not-prevent-usage-of-tee.json @@ -0,0 +1,43 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuEDkFdMN01PVlzzLwbFr98W0jM1N\",\"object\":\"chat.completion.chunk\",\"created\":1752740732,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEDkFdMN01PVlzzLwbFr98W0jM1N\",\"object\":\"chat.completion.chunk\",\"created\":1752740732,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Atlantic\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEDkFdMN01PVlzzLwbFr98W0jM1N\",\"object\":\"chat.completion.chunk\",\"created\":1752740732,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEDkFdMN01PVlzzLwbFr98W0jM1N\",\"object\":\"chat.completion.chunk\",\"created\":1752740732,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuEDkFdMN01PVlzzLwbFr98W0jM1N\",\"object\":\"chat.completion.chunk\",\"created\":1752740732,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "9608596839e18a5d-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:25:32 GMT", + "openai-processing-ms": "238", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "241", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999982", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_0d087f5cec909f11f1e95347619bedd5" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-multiple-choices.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-multiple-choices.json new file mode 100644 index 0000000000..233d271209 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-multiple-choices.json @@ -0,0 +1,44 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "n": 2, + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Atlantic\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{\"content\":\"Southern\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"chatcmpl-BuDPruvXvy1cTouU79MhRWdmZWMqk\",\"object\":\"chat.completion.chunk\",\"created\":1752737639,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96080de51fa847f7-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 07:33:59 GMT", + "openai-processing-ms": "209", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "217", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999981", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_fa9c7a16b29cca45d28032fdb0d910b6" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-tool-calls.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-tool-calls.json new file mode 100644 index 0000000000..f28bddb43f --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-tool-calls.json @@ -0,0 +1,170 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant providing weather updates." + }, + { + "role": "user", + "content": "What is the weather in New York City and London?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + } + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_9ujI2ZExKzIGa57dsFCuwSXI\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"lo\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"catio\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"n\\\": \\\"N\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ew Y\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ork C\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ity\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_M5Jmiz7Y7ZUiASk3ShRROpUr\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"{\\\"lo\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"catio\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"n\\\": \\\"L\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"ondo\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"n\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpRr8h0kwBLc53wzb0GeYXsWCcX\",\"object\":\"chat.completion.chunk\",\"created\":1752739225,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "960834979c54e366-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:00:26 GMT", + "openai-processing-ms": "1046", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "1055", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999970", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_222b8015f2cab35df65c97bfd8e226f0" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant providing weather updates." + }, + { + "role": "user", + "content": "What is the weather in New York City and London?" + }, + { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_9ujI2ZExKzIGa57dsFCuwSXI", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"New York City\"}" + } + }, + { + "index": 1, + "id": "call_M5Jmiz7Y7ZUiASk3ShRROpUr", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"London\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_9ujI2ZExKzIGa57dsFCuwSXI", + "content": "25 degrees and sunny" + }, + { + "role": "tool", + "tool_call_id": "call_M5Jmiz7Y7ZUiASk3ShRROpUr", + "content": "15 degrees and raining" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + } + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" New\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" York\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" City\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"25\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" degrees\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" while\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" London\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" it\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"15\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" degrees\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" raining\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDpTOhzJCQLCyjQ8OcbJsShIN7XM\",\"object\":\"chat.completion.chunk\",\"created\":1752739227,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "960834a86d2f3c17-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:00:27 GMT", + "openai-organization": "elastic-observability", + "openai-processing-ms": "240", + "openai-project": "proj_Pf1eM5R55Z35wBy4rt8PxAGq", + "openai-version": "2020-10-01", + "server": "cloudflare", + "set-cookie": "_cfuvid=9MBw8QqOzZBRJtGKGZM9zTdRPGaWSVHP.cA.zk.50QY-1752739227664-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "244", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999957", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_ba535371fdc85f890162da3f3ddaa840" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-usage.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-usage.json new file mode 100644 index 0000000000..1814791462 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-records-usage.json @@ -0,0 +1,46 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "stream": true, + "stream_options": { + "include_usage": true + } + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDrRRWybY6JHzabaUyR2OtaEGp79\",\"object\":\"chat.completion.chunk\",\"created\":1752739349,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"chatcmpl-BuDrRRWybY6JHzabaUyR2OtaEGp79\",\"object\":\"chat.completion.chunk\",\"created\":1752739349,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"South\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"chatcmpl-BuDrRRWybY6JHzabaUyR2OtaEGp79\",\"object\":\"chat.completion.chunk\",\"created\":1752739349,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Atlantic\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"chatcmpl-BuDrRRWybY6JHzabaUyR2OtaEGp79\",\"object\":\"chat.completion.chunk\",\"created\":1752739349,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"chatcmpl-BuDrRRWybY6JHzabaUyR2OtaEGp79\",\"object\":\"chat.completion.chunk\",\"created\":1752739349,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"chatcmpl-BuDrRRWybY6JHzabaUyR2OtaEGp79\",\"object\":\"chat.completion.chunk\",\"created\":1752739349,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null}\n\ndata: {\"id\":\"chatcmpl-BuDrRRWybY6JHzabaUyR2OtaEGp79\",\"object\":\"chat.completion.chunk\",\"created\":1752739349,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":4,\"total_tokens\":26,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}}}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "960837a3a8faf6d9-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:02:29 GMT", + "openai-processing-ms": "281", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "286", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999982", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_539e03a5bdb03c6f6b3010f8c5389109" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-adds-genai-conventions.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-adds-genai-conventions.json new file mode 100644 index 0000000000..2f11313834 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-adds-genai-conventions.json @@ -0,0 +1,43 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDvE94ISQVUWZdbkJ6k1XcOMmyzg\",\"object\":\"chat.completion.chunk\",\"created\":1752739584,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvE94ISQVUWZdbkJ6k1XcOMmyzg\",\"object\":\"chat.completion.chunk\",\"created\":1752739584,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Atlantic\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvE94ISQVUWZdbkJ6k1XcOMmyzg\",\"object\":\"chat.completion.chunk\",\"created\":1752739584,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvE94ISQVUWZdbkJ6k1XcOMmyzg\",\"object\":\"chat.completion.chunk\",\"created\":1752739584,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvE94ISQVUWZdbkJ6k1XcOMmyzg\",\"object\":\"chat.completion.chunk\",\"created\":1752739584,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96083d620bffd4fb-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:06:24 GMT", + "openai-processing-ms": "199", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "204", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999983", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_be25c8ec8b23a1dbb37740b72311c307" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-records-multiple-choices.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-records-multiple-choices.json new file mode 100644 index 0000000000..5b5b646946 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-records-multiple-choices.json @@ -0,0 +1,44 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Answer in up to 3 words: Which ocean contains Bouvet Island?" + } + ], + "n": 2, + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Southern\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{\"content\":\"Atlantic\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{\"content\":\" Ocean\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFELsVfoy6ztg2r7kEkFtC8lyI\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":1,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96083d658a73d788-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:06:25 GMT", + "openai-processing-ms": "176", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "178", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999981", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_b15fb55c152b988c9c798401679ca46e" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-records-tool-calls.json b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-records-tool-calls.json new file mode 100644 index 0000000000..54d80e9c22 --- /dev/null +++ b/packages/instrumentation-openai/test/mock-responses/openai-streaming-chat-completions-with-content-capture-records-tool-calls.json @@ -0,0 +1,170 @@ +[ + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant providing weather updates." + }, + { + "role": "user", + "content": "What is the weather in New York City and London?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + } + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_8mgxuCkNPx3qlomni7YPgFmd\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"lo\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"catio\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"n\\\": \\\"N\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ew Y\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ork C\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ity\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_oPnYBRvnkzPFgA0R33zwifZP\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"{\\\"lo\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"catio\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"n\\\": \\\"L\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"ondo\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"n\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvFNfcuXkfEXklmzyIFrJJb4bG0\",\"object\":\"chat.completion.chunk\",\"created\":1752739585,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96083d68daf3d758-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:06:26 GMT", + "openai-processing-ms": "949", + "openai-version": "2020-10-01", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "952", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999970", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_5955d1bb0d741299c6fb0fef17e3cf5c" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.openai.com:443", + "method": "POST", + "path": "/v1/chat/completions", + "body": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant providing weather updates." + }, + { + "role": "user", + "content": "What is the weather in New York City and London?" + }, + { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_8mgxuCkNPx3qlomni7YPgFmd", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"New York City\"}" + } + }, + { + "index": 1, + "id": "call_oPnYBRvnkzPFgA0R33zwifZP", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"London\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_8mgxuCkNPx3qlomni7YPgFmd", + "content": "25 degrees and sunny" + }, + { + "role": "tool", + "tool_call_id": "call_oPnYBRvnkzPFgA0R33zwifZP", + "content": "15 degrees and raining" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "additionalProperties": false + } + } + } + ], + "stream": true + }, + "status": 200, + "response": "data: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"25\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" degrees\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" New\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" York\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" City\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" while\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" London\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" it\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"15\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" degrees\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" raining\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-BuDvHbKGyexqi9yq72brOvY4WfZMH\",\"object\":\"chat.completion.chunk\",\"created\":1752739587,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "rawHeaders": { + "access-control-expose-headers": "X-Request-ID", + "alt-svc": "h3=\":443\"; ma=86400", + "cf-cache-status": "DYNAMIC", + "cf-ray": "96083d71fdde6836-NRT", + "connection": "close", + "content-type": "text/event-stream; charset=utf-8", + "date": "Thu, 17 Jul 2025 08:06:27 GMT", + "openai-organization": "elastic-observability", + "openai-processing-ms": "195", + "openai-project": "proj_Pf1eM5R55Z35wBy4rt8PxAGq", + "openai-version": "2020-10-01", + "server": "cloudflare", + "set-cookie": "_cfuvid=xDfDRpDRw.EnLw2M2RDvnWf649EQmohfTJG_qqGE6iw-1752739587245-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "transfer-encoding": "chunked", + "x-content-type-options": "nosniff", + "x-envoy-upstream-service-time": "198", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999958", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "x-request-id": "req_536598da01504470d10172c7f5346a9e" + }, + "responseIsBinary": false + } +] \ No newline at end of file diff --git a/packages/instrumentation-openai/test/openai.test.ts b/packages/instrumentation-openai/test/openai.test.ts new file mode 100644 index 0000000000..21402f2ce3 --- /dev/null +++ b/packages/instrumentation-openai/test/openai.test.ts @@ -0,0 +1,3198 @@ +/* + * 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, OPENAI_API_KEY must be set to + * a valid API key. To record for new tests while + * keeping existing recordings, set NOCK_BACK_MODE to 'record'. + */ + +import { + contentCaptureInstrumentation, + instrumentation, + loggerProvider, + logsExporter, + meterProvider, + metricExporter, +} from './load-instrumentation'; +import { + getTestSpans, + resetMemoryExporter, +} from '@opentelemetry/contrib-test-utils'; +import { + ATTR_ERROR_TYPE, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; +import { expect } from 'expect'; +import { Definition, back as nockBack } from 'nock'; +import { OpenAI } from 'openai'; +import * as path from 'path'; + +import { + ATTR_EVENT_NAME, + ATTR_GEN_AI_SYSTEM, + ATTR_GEN_AI_OPERATION_NAME, + ATTR_GEN_AI_REQUEST_ENCODING_FORMATS, + ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY, + ATTR_GEN_AI_REQUEST_MODEL, + ATTR_GEN_AI_REQUEST_MAX_TOKENS, + ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY, + ATTR_GEN_AI_REQUEST_TOP_P, + ATTR_GEN_AI_USAGE_INPUT_TOKENS, + ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, + ATTR_GEN_AI_RESPONSE_FINISH_REASONS, + ATTR_GEN_AI_RESPONSE_ID, + ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, + ATTR_GEN_AI_REQUEST_TEMPERATURE, + ATTR_GEN_AI_RESPONSE_MODEL, + ATTR_GEN_AI_TOKEN_TYPE, +} from '../src/semconv'; + +// 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: Record = (scope as any).rawHeaders; + delete headers['set-cookie']; + delete headers['openai-organization']; + delete headers['openai-project']; + } + return scopes; +}; + +describe('OpenAI', function () { + this.timeout(10000); // Increase timeout for LLM tests + + nockBack.fixtures = path.join(__dirname, 'mock-responses'); + let apiKey: string | undefined; + if (nockBack.currentMode === 'dryrun') { + apiKey = 'testing'; + } + + 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(); + + await loggerProvider.forceFlush(); + logsExporter.reset(); + + await meterProvider.forceFlush(); + metricExporter.reset(); + }); + + const client = new OpenAI({ apiKey }); + const model = 'gpt-4o-mini'; + const input = 'Answer in up to 3 words: Which ocean contains Bouvet Island?'; + + describe('chat completions', function () { + this.beforeEach(() => { + instrumentation.enable(); + }); + this.afterEach(() => { + instrumentation.disable(); + }); + + it('adds genai conventions', async () => { + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: input, + }, + ]; + const chatCompletion = await client.chat.completions.create({ + model, + messages, + }); + expect(chatCompletion.choices[0].message.content).toEqual( + 'Atlantic Ocean.' + ); + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 22, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 3, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(2); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 22, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 3, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(2); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + }); + + it('records all the client options', async () => { + const messages = [ + { + role: 'user', + content: input, + }, + ] satisfies OpenAI.Chat.Completions.ChatCompletionMessageParam[]; + const chatCompletion = await client.chat.completions.create({ + model, + messages, + frequency_penalty: 0.0, + max_tokens: 100, + presence_penalty: 0.0, + temperature: 1.0, + top_p: 1.0, + stop: 'foo', + seed: 100, + response_format: { + type: 'text', + }, + }); + expect(chatCompletion.choices[0].message.content).toEqual( + 'Southern Ocean.' + ); + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY]: 0.0, + [ATTR_GEN_AI_REQUEST_MAX_TOKENS]: 100, + [ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY]: 0.0, + [ATTR_GEN_AI_REQUEST_TEMPERATURE]: 1.0, + [ATTR_GEN_AI_REQUEST_TOP_P]: 1.0, + [ATTR_GEN_AI_REQUEST_STOP_SEQUENCES]: ['foo'], + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 22, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 3, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(2); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 22, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 3, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(2); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + }); + + it('records multiple choices', async () => { + const messages = [ + { + role: 'user', + content: input, + }, + ] satisfies OpenAI.Chat.Completions.ChatCompletionMessageParam[]; + const chatCompletion = await client.chat.completions.create({ + model, + messages, + n: 2, + }); + expect(chatCompletion.choices[0].message.content).toEqual( + 'Atlantic Ocean.' + ); + expect(chatCompletion.choices[1].message.content).toEqual( + 'Southern Ocean.' + ); + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop', 'stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 22, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 6, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(2); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 22, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 6, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(3); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + expect(logs[2].spanContext).toEqual(spanCtx); + expect(logs[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[2].body).toEqual({ + finish_reason: 'stop', + index: 1, + message: {}, + }); + }); + + it('records tool calls', async () => { + const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ + { + type: 'function', + function: { + name: 'get_weather', + strict: true, + parameters: { + type: 'object', + properties: { + location: { type: 'string' }, + }, + required: ['location'], + additionalProperties: false, + }, + }, + }, + ]; + + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'system', + content: 'You are a helpful assistant providing weather updates.', + }, + { + role: 'user', + content: 'What is the weather in New York City and London?', + }, + ]; + const chatCompletion = await client.chat.completions.create({ + model, + messages, + tools, + }); + expect(chatCompletion.choices[0].message.content).toBeNull(); + + const toolCalls = chatCompletion.choices[0].message.tool_calls!; + expect(toolCalls.length).toBe(2); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['tool_calls'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 57, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 46, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(2); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 57, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 46, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(3); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.system.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[2].body).toEqual({ + finish_reason: 'tool_calls', + index: 0, + message: { + tool_calls: [ + { + type: 'function', + id: toolCalls[0].id, + function: { + name: 'get_weather', + }, + }, + { + type: 'function', + id: toolCalls[1].id, + function: { + name: 'get_weather', + }, + }, + ], + }, + }); + + metricExporter.reset(); + logsExporter.reset(); + resetMemoryExporter(); + + messages.push({ + role: 'assistant', + tool_calls: toolCalls, + }); + messages.push({ + role: 'tool', + tool_call_id: toolCalls[0].id, + content: '25 degrees and sunny', + }); + messages.push({ + role: 'tool', + tool_call_id: toolCalls[1].id, + content: '15 degrees and raining', + }); + + const chatCompletion1 = await client.chat.completions.create({ + model, + messages, + tools, + }); + + expect(chatCompletion1.choices[0].message.content).toBe( + 'The weather in New York City is 25 degrees and sunny, while in London, it is 15 degrees and raining.' + ); + + const spans1 = getTestSpans(); + expect(spans1.length).toBe(1); + expect(spans1[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 125, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 26, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics1] = metricExporter.getMetrics(); + expect(resourceMetrics1.scopeMetrics.length).toBe(1); + const scopeMetrics1 = resourceMetrics1.scopeMetrics[0]; + const tokenUsage1 = scopeMetrics1.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage1.length).toBe(1); + expect(tokenUsage1[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage1[0].dataPoints.length).toBe(2); + expect(tokenUsage1[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 125, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 26, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration1 = scopeMetrics1.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration1.length).toBe(1); + expect(operationDuration1[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration1[0].dataPoints.length).toBe(1); + expect(operationDuration1[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration1[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx1 = spans1[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs1 = logsExporter.getFinishedLogRecords(); + expect(logs1.length).toBe(6); + expect(logs1[0].spanContext).toEqual(spanCtx1); + expect(logs1[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.system.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[0].body).toEqual({}); + expect(logs1[1].spanContext).toEqual(spanCtx1); + expect(logs1[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[1].body).toEqual({}); + expect(logs1[2].spanContext).toEqual(spanCtx1); + expect(logs1[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.assistant.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[2].body).toEqual({ + tool_calls: [ + { + type: 'function', + id: toolCalls[0].id, + function: { + name: 'get_weather', + }, + }, + { + type: 'function', + id: toolCalls[1].id, + function: { + name: 'get_weather', + }, + }, + ], + }); + expect(logs1[3].spanContext).toEqual(spanCtx1); + expect(logs1[3].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.tool.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[3].body).toEqual({ + id: toolCalls[0].id, + }); + expect(logs1[4].spanContext).toEqual(spanCtx1); + expect(logs1[4].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.tool.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[4].body).toEqual({ + id: toolCalls[1].id, + }); + expect(logs1[5].spanContext).toEqual(spanCtx1); + expect(logs1[5].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[5].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + }); + + it('handles connection errors without crashing', async () => { + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: input, + }, + ]; + expect( + new OpenAI({ + baseURL: 'http://localhost:9999/v5', + apiKey, + }).chat.completions.create({ + model, + messages, + }) + ).rejects.toThrow(OpenAI.APIConnectionError); + + // TODO: Figure out why it takes so long to get this span. trace.getTracerProvider()._delegate.forceFlush() didn't help. + await new Promise(resolve => setTimeout(resolve, 2000)); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'localhost', + [ATTR_SERVER_PORT]: 9999, + [ATTR_ERROR_TYPE]: 'APIConnectionError', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + expect(tokenUsage).toHaveLength(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_SERVER_ADDRESS]: 'localhost', + [ATTR_SERVER_PORT]: 9999, + [ATTR_ERROR_TYPE]: 'APIConnectionError', + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(1); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + }); + }); + + describe('chat completions with content capture', function () { + this.beforeEach(() => { + contentCaptureInstrumentation.enable(); + }); + this.afterEach(() => { + contentCaptureInstrumentation.disable(); + }); + + it('adds genai conventions', async () => { + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: input, + }, + ]; + const chatCompletion = await client.chat.completions.create({ + model, + messages, + }); + expect(chatCompletion.choices[0].message.content).toEqual( + 'South Atlantic Ocean.' + ); + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 22, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 4, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(2); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 22, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 4, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(2); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({ + content: input, + }); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: { + content: 'South Atlantic Ocean.', + }, + }); + }); + + it('records multiple choices', async () => { + const messages = [ + { + role: 'user', + content: input, + }, + ] satisfies OpenAI.Chat.Completions.ChatCompletionMessageParam[]; + const chatCompletion = await client.chat.completions.create({ + model, + messages, + n: 2, + }); + expect(chatCompletion.choices[0].message.content).toEqual( + 'South Atlantic Ocean.' + ); + expect(chatCompletion.choices[1].message.content).toEqual( + 'Southern Ocean' + ); + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop', 'stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 22, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 6, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(2); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 22, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 6, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(3); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({ + content: input, + }); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: { + content: 'South Atlantic Ocean.', + }, + }); + expect(logs[2].spanContext).toEqual(spanCtx); + expect(logs[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[2].body).toEqual({ + finish_reason: 'stop', + index: 1, + message: { + content: 'Southern Ocean', + }, + }); + }); + + it('records tool calls', async () => { + const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ + { + type: 'function', + function: { + name: 'get_weather', + strict: true, + parameters: { + type: 'object', + properties: { + location: { type: 'string' }, + }, + required: ['location'], + additionalProperties: false, + }, + }, + }, + ]; + + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'system', + content: 'You are a helpful assistant providing weather updates.', + }, + { + role: 'user', + content: 'What is the weather in New York City and London?', + }, + ]; + const chatCompletion = await client.chat.completions.create({ + model, + messages, + tools, + }); + expect(chatCompletion.choices[0].message.content).toBeNull(); + + const toolCalls = chatCompletion.choices[0].message.tool_calls!; + expect(toolCalls.length).toBe(2); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['tool_calls'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 57, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 46, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(2); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 57, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 46, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(3); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.system.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({ + content: 'You are a helpful assistant providing weather updates.', + }); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + content: 'What is the weather in New York City and London?', + }); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[2].body).toEqual({ + finish_reason: 'tool_calls', + index: 0, + message: { + tool_calls: [ + { + type: 'function', + id: toolCalls[0].id, + function: { + name: 'get_weather', + arguments: '{"location": "New York City"}', + }, + }, + { + type: 'function', + id: toolCalls[1].id, + function: { + name: 'get_weather', + arguments: '{"location": "London"}', + }, + }, + ], + }, + }); + + metricExporter.reset(); + logsExporter.reset(); + resetMemoryExporter(); + + messages.push({ + role: 'assistant', + tool_calls: toolCalls, + }); + messages.push({ + role: 'tool', + tool_call_id: toolCalls[0].id, + content: '25 degrees and sunny', + }); + messages.push({ + role: 'tool', + tool_call_id: toolCalls[1].id, + content: '15 degrees and raining', + }); + + const chatCompletion1 = await client.chat.completions.create({ + model, + messages, + tools, + }); + + expect(chatCompletion1.choices[0].message.content).toBe( + 'The weather is currently 25 degrees and sunny in New York City, while in London, it is 15 degrees and raining.' + ); + + const spans1 = getTestSpans(); + expect(spans1.length).toBe(1); + expect(spans1[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 125, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 27, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics1] = metricExporter.getMetrics(); + expect(resourceMetrics1.scopeMetrics.length).toBe(1); + const scopeMetrics1 = resourceMetrics1.scopeMetrics[0]; + const tokenUsage1 = scopeMetrics1.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage1.length).toBe(1); + expect(tokenUsage1[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage1[0].dataPoints.length).toBe(2); + expect(tokenUsage1[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 125, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 27, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration1 = scopeMetrics1.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration1.length).toBe(1); + expect(operationDuration1[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration1[0].dataPoints.length).toBe(1); + expect(operationDuration1[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration1[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx1 = spans1[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs1 = logsExporter.getFinishedLogRecords(); + expect(logs1.length).toBe(6); + expect(logs1[0].spanContext).toEqual(spanCtx1); + expect(logs1[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.system.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[0].body).toEqual({ + content: 'You are a helpful assistant providing weather updates.', + }); + expect(logs1[1].spanContext).toEqual(spanCtx1); + expect(logs1[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[1].body).toEqual({ + content: 'What is the weather in New York City and London?', + }); + expect(logs1[2].spanContext).toEqual(spanCtx1); + expect(logs1[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.assistant.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[2].body).toEqual({ + tool_calls: [ + { + type: 'function', + id: toolCalls[0].id, + function: { + name: 'get_weather', + arguments: '{"location": "New York City"}', + }, + }, + { + type: 'function', + id: toolCalls[1].id, + function: { + name: 'get_weather', + arguments: '{"location": "London"}', + }, + }, + ], + }); + expect(logs1[3].spanContext).toEqual(spanCtx1); + expect(logs1[3].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.tool.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[3].body).toEqual({ + id: toolCalls[0].id, + content: '25 degrees and sunny', + }); + expect(logs1[4].spanContext).toEqual(spanCtx1); + expect(logs1[4].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.tool.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[4].body).toEqual({ + id: toolCalls[1].id, + content: '15 degrees and raining', + }); + expect(logs1[5].spanContext).toEqual(spanCtx1); + expect(logs1[5].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[5].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: { + content: + 'The weather is currently 25 degrees and sunny in New York City, while in London, it is 15 degrees and raining.', + }, + }); + }); + }); + + describe('streaming chat completions', function () { + this.beforeEach(() => { + instrumentation.enable(); + }); + this.afterEach(() => { + instrumentation.disable(); + }); + + it('adds genai conventions', async () => { + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: input, + }, + ]; + const stream = await client.chat.completions.create({ + model, + messages, + stream: true, + }); + let content = ''; + for await (const part of stream) { + content += part.choices[0].delta.content || ''; + } + expect(content).toEqual('Atlantic Ocean.'); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(2); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + }); + + it('records usage', async () => { + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: input, + }, + ]; + const stream = await client.chat.completions.create({ + model, + messages, + stream: true, + stream_options: { + include_usage: true, + }, + }); + let content = ''; + for await (const part of stream) { + if (part.choices[0]) { + content += part.choices[0].delta.content || ''; + } + } + expect(content).toEqual('South Atlantic Ocean.'); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 22, + [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 4, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(2); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 22, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + expect.objectContaining({ + value: expect.objectContaining({ + sum: 4, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_TOKEN_TYPE]: 'output', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(2); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + }); + + it('records multiple choices', async () => { + const messages = [ + { + role: 'user', + content: input, + }, + ] satisfies OpenAI.Chat.Completions.ChatCompletionMessageParam[]; + const stream = await client.chat.completions.create({ + model, + messages, + n: 2, + stream: true, + }); + const choices: string[] = []; + for await (const part of stream) { + const idx = part.choices[0].index; + if (!choices[idx]) { + choices[idx] = ''; + } + choices[idx] += part.choices[0].delta.content || ''; + } + expect(choices[0]).toEqual('Atlantic Ocean.'); + expect(choices[1]).toEqual('Southern Ocean.'); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop', 'stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(3); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + expect(logs[2].spanContext).toEqual(spanCtx); + expect(logs[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[2].body).toEqual({ + finish_reason: 'stop', + index: 1, + message: {}, + }); + }); + + it('records tool calls', async () => { + const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ + { + type: 'function', + function: { + name: 'get_weather', + strict: true, + parameters: { + type: 'object', + properties: { + location: { type: 'string' }, + }, + required: ['location'], + additionalProperties: false, + }, + }, + }, + ]; + + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'system', + content: 'You are a helpful assistant providing weather updates.', + }, + { + role: 'user', + content: 'What is the weather in New York City and London?', + }, + ]; + const stream = await client.chat.completions.create({ + model, + messages, + tools, + stream: true, + }); + + const toolCalls: any[] = []; + for await (const part of stream) { + const delta = part.choices[0].delta; + if (delta.tool_calls) { + for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + toolCalls[toolCall.index] = toolCall; + } else { + toolCalls[toolCall.index] = { + ...toolCalls[toolCall.index], + function: { + ...toolCalls[toolCall.index].function, + arguments: + (toolCalls[toolCall.index].function?.arguments ?? '') + + (toolCall.function?.arguments ?? ''), + }, + }; + } + } + } + } + expect(toolCalls.length).toBe(2); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['tool_calls'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(3); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.system.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[2].body).toEqual({ + finish_reason: 'tool_calls', + index: 0, + message: { + tool_calls: [ + { + type: 'function', + id: toolCalls[0].id, + function: { + name: 'get_weather', + }, + }, + { + type: 'function', + id: toolCalls[1].id, + function: { + name: 'get_weather', + }, + }, + ], + }, + }); + + metricExporter.reset(); + logsExporter.reset(); + resetMemoryExporter(); + + messages.push({ + role: 'assistant', + tool_calls: toolCalls, + }); + messages.push({ + role: 'tool', + tool_call_id: toolCalls[0].id, + content: '25 degrees and sunny', + }); + messages.push({ + role: 'tool', + tool_call_id: toolCalls[1].id, + content: '15 degrees and raining', + }); + + const chatCompletion1 = await client.chat.completions.create({ + model, + messages, + tools, + stream: true, + }); + + let content = ''; + for await (const part of chatCompletion1) { + content += part.choices[0].delta.content || ''; + } + + expect(content).toBe( + 'The weather in New York City is 25 degrees and sunny, while in London, it is 15 degrees and raining.' + ); + + const spans1 = getTestSpans(); + expect(spans1.length).toBe(1); + expect(spans1[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics1] = metricExporter.getMetrics(); + expect(resourceMetrics1.scopeMetrics.length).toBe(1); + const scopeMetrics1 = resourceMetrics1.scopeMetrics[0]; + const tokenUsage1 = scopeMetrics1.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage1.length).toBe(0); + + const operationDuration1 = scopeMetrics1.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration1.length).toBe(1); + expect(operationDuration1[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration1[0].dataPoints.length).toBe(1); + expect(operationDuration1[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration1[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx1 = spans1[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs1 = logsExporter.getFinishedLogRecords(); + expect(logs1.length).toBe(6); + expect(logs1[0].spanContext).toEqual(spanCtx1); + expect(logs1[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.system.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[0].body).toEqual({}); + expect(logs1[1].spanContext).toEqual(spanCtx1); + expect(logs1[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[1].body).toEqual({}); + expect(logs1[2].spanContext).toEqual(spanCtx1); + expect(logs1[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.assistant.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[2].body).toEqual({ + tool_calls: [ + { + type: 'function', + id: toolCalls[0].id, + function: { + name: 'get_weather', + }, + }, + { + type: 'function', + id: toolCalls[1].id, + function: { + name: 'get_weather', + }, + }, + ], + }); + expect(logs1[3].spanContext).toEqual(spanCtx1); + expect(logs1[3].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.tool.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[3].body).toEqual({ + id: toolCalls[0].id, + }); + expect(logs1[4].spanContext).toEqual(spanCtx1); + expect(logs1[4].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.tool.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[4].body).toEqual({ + id: toolCalls[1].id, + }); + expect(logs1[5].spanContext).toEqual(spanCtx1); + expect(logs1[5].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[5].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + }); + + it('does not misbehave with double iteration', async () => { + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: input, + }, + ]; + const stream = await client.chat.completions.create({ + model, + messages, + stream: true, + }); + let content = ''; + for await (const part of stream) { + content += part.choices[0].delta.content || ''; + } + expect(async () => { + for await (const part of stream) { + content += part.choices[0].delta.content || ''; + } + }).rejects.toThrow(); + expect(content).toEqual('South Atlantic Ocean.'); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(2); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + }); + + it('does not prevent usage of tee', async () => { + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: input, + }, + ]; + const stream = await client.chat.completions.create({ + model, + messages, + stream: true, + }); + let content0 = ''; + const [left, right] = stream.tee(); + for await (const part of left) { + content0 += part.choices[0].delta.content || ''; + } + let content1 = ''; + for await (const part of right) { + content1 += part.choices[0].delta.content || ''; + } + expect(content0).toEqual('Atlantic Ocean.'); + expect(content1).toEqual('Atlantic Ocean.'); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(2); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({}); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: {}, + }); + }); + }); + + describe('streaming chat completions with content capture', function () { + this.beforeEach(() => { + contentCaptureInstrumentation.enable(); + }); + this.afterEach(() => { + contentCaptureInstrumentation.disable(); + }); + + it('adds genai conventions', async () => { + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: input, + }, + ]; + const stream = await client.chat.completions.create({ + model, + messages, + stream: true, + }); + let content = ''; + for await (const part of stream) { + content += part.choices[0].delta.content || ''; + } + expect(content).toEqual('Atlantic Ocean.'); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(2); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({ + content: input, + }); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: { + content: 'Atlantic Ocean.', + }, + }); + }); + + it('records multiple choices', async () => { + const messages = [ + { + role: 'user', + content: input, + }, + ] satisfies OpenAI.Chat.Completions.ChatCompletionMessageParam[]; + const stream = await client.chat.completions.create({ + model, + messages, + n: 2, + stream: true, + }); + const choices: string[] = []; + for await (const part of stream) { + const idx = part.choices[0].index; + if (!choices[idx]) { + choices[idx] = ''; + } + choices[idx] += part.choices[0].delta.content || ''; + } + expect(choices[0]).toEqual('Southern Ocean.'); + expect(choices[1]).toEqual('Atlantic Ocean.'); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop', 'stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(3); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({ + content: input, + }); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: { + content: 'Southern Ocean.', + }, + }); + expect(logs[2].spanContext).toEqual(spanCtx); + expect(logs[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[2].body).toEqual({ + finish_reason: 'stop', + index: 1, + message: { + content: 'Atlantic Ocean.', + }, + }); + }); + + it('records tool calls', async () => { + const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ + { + type: 'function', + function: { + name: 'get_weather', + strict: true, + parameters: { + type: 'object', + properties: { + location: { type: 'string' }, + }, + required: ['location'], + additionalProperties: false, + }, + }, + }, + ]; + + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'system', + content: 'You are a helpful assistant providing weather updates.', + }, + { + role: 'user', + content: 'What is the weather in New York City and London?', + }, + ]; + const stream = await client.chat.completions.create({ + model, + messages, + tools, + stream: true, + }); + + const toolCalls: any[] = []; + for await (const part of stream) { + const delta = part.choices[0].delta; + if (delta.tool_calls) { + for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + toolCalls[toolCall.index] = toolCall; + } else { + toolCalls[toolCall.index] = { + ...toolCalls[toolCall.index], + function: { + ...toolCalls[toolCall.index].function, + arguments: + (toolCalls[toolCall.index].function?.arguments ?? '') + + (toolCall.function?.arguments ?? ''), + }, + }; + } + } + } + } + expect(toolCalls.length).toBe(2); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['tool_calls'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(0); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx = spans[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs = logsExporter.getFinishedLogRecords(); + expect(logs.length).toBe(3); + expect(logs[0].spanContext).toEqual(spanCtx); + expect(logs[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.system.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[0].body).toEqual({ + content: 'You are a helpful assistant providing weather updates.', + }); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[1].body).toEqual({ + content: 'What is the weather in New York City and London?', + }); + expect(logs[1].spanContext).toEqual(spanCtx); + expect(logs[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs[2].body).toEqual({ + finish_reason: 'tool_calls', + index: 0, + message: { + tool_calls: [ + { + type: 'function', + id: toolCalls[0].id, + function: { + name: 'get_weather', + arguments: '{"location": "New York City"}', + }, + }, + { + type: 'function', + id: toolCalls[1].id, + function: { + name: 'get_weather', + arguments: '{"location": "London"}', + }, + }, + ], + }, + }); + + metricExporter.reset(); + logsExporter.reset(); + resetMemoryExporter(); + + messages.push({ + role: 'assistant', + tool_calls: toolCalls, + }); + messages.push({ + role: 'tool', + tool_call_id: toolCalls[0].id, + content: '25 degrees and sunny', + }); + messages.push({ + role: 'tool', + tool_call_id: toolCalls[1].id, + content: '15 degrees and raining', + }); + + const chatCompletion1 = await client.chat.completions.create({ + model, + messages, + tools, + stream: true, + }); + + let content = ''; + for await (const part of chatCompletion1) { + content += part.choices[0].delta.content || ''; + } + + expect(content).toBe( + 'The weather is currently 25 degrees and sunny in New York City, while in London, it is 15 degrees and raining.' + ); + + const spans1 = getTestSpans(); + expect(spans1.length).toBe(1); + expect(spans1[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ['stop'], + [ATTR_GEN_AI_RESPONSE_ID]: expect.stringMatching(/^chatcmpl-/), + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + }); + + await meterProvider.forceFlush(); + const [resourceMetrics1] = metricExporter.getMetrics(); + expect(resourceMetrics1.scopeMetrics.length).toBe(1); + const scopeMetrics1 = resourceMetrics1.scopeMetrics[0]; + const tokenUsage1 = scopeMetrics1.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage1.length).toBe(0); + + const operationDuration1 = scopeMetrics1.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration1.length).toBe(1); + expect(operationDuration1[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration1[0].dataPoints.length).toBe(1); + expect(operationDuration1[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'chat', + [ATTR_GEN_AI_REQUEST_MODEL]: model, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'gpt-4o-mini-2024-07-18', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration1[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + + const spanCtx1 = spans1[0].spanContext(); + + await loggerProvider.forceFlush(); + const logs1 = logsExporter.getFinishedLogRecords(); + expect(logs1.length).toBe(6); + expect(logs1[0].spanContext).toEqual(spanCtx1); + expect(logs1[0].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.system.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[0].body).toEqual({ + content: 'You are a helpful assistant providing weather updates.', + }); + expect(logs1[1].spanContext).toEqual(spanCtx1); + expect(logs1[1].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.user.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[1].body).toEqual({ + content: 'What is the weather in New York City and London?', + }); + expect(logs1[2].spanContext).toEqual(spanCtx1); + expect(logs1[2].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.assistant.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[2].body).toEqual({ + tool_calls: [ + { + type: 'function', + id: toolCalls[0].id, + function: { + name: 'get_weather', + arguments: '{"location": "New York City"}', + }, + }, + { + type: 'function', + id: toolCalls[1].id, + function: { + name: 'get_weather', + arguments: '{"location": "London"}', + }, + }, + ], + }); + expect(logs1[3].spanContext).toEqual(spanCtx1); + expect(logs1[3].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.tool.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[3].body).toEqual({ + id: toolCalls[0].id, + content: '25 degrees and sunny', + }); + expect(logs1[4].spanContext).toEqual(spanCtx1); + expect(logs1[4].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.tool.message', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[4].body).toEqual({ + id: toolCalls[1].id, + content: '15 degrees and raining', + }); + expect(logs1[5].spanContext).toEqual(spanCtx1); + expect(logs1[5].attributes).toEqual({ + [ATTR_EVENT_NAME]: 'gen_ai.choice', + [ATTR_GEN_AI_SYSTEM]: 'openai', + }); + expect(logs1[5].body).toEqual({ + finish_reason: 'stop', + index: 0, + message: { + content: + 'The weather is currently 25 degrees and sunny in New York City, while in London, it is 15 degrees and raining.', + }, + }); + }); + }); + + describe('embeddings', function () { + this.beforeEach(() => { + instrumentation.enable(); + }); + this.afterEach(() => { + instrumentation.disable(); + }); + + it('adds genai conventions', async () => { + const embedding = await client.embeddings.create({ + model: 'text-embedding-3-small', + input: ['One fish', 'two fish', 'red fish', 'blue fish'], + encoding_format: 'float', + }); + expect(embedding.data.length).toBe(4); + + const spans = getTestSpans(); + expect(spans.length).toBe(1); + expect(spans[0].attributes).toEqual({ + [ATTR_GEN_AI_OPERATION_NAME]: 'embeddings', + [ATTR_GEN_AI_REQUEST_MODEL]: 'text-embedding-3-small', + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + [ATTR_GEN_AI_RESPONSE_MODEL]: 'text-embedding-3-small', + [ATTR_GEN_AI_REQUEST_ENCODING_FORMATS]: ['float'], + [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 8, + }); + + await meterProvider.forceFlush(); + const [resourceMetrics] = metricExporter.getMetrics(); + expect(resourceMetrics.scopeMetrics.length).toBe(1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + const tokenUsage = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.token.usage' + ); + + expect(tokenUsage.length).toBe(1); + expect(tokenUsage[0].descriptor).toMatchObject({ + name: 'gen_ai.client.token.usage', + type: 'HISTOGRAM', + description: 'Measures number of input and output tokens used', + unit: '{token}', + }); + expect(tokenUsage[0].dataPoints.length).toBe(1); + expect(tokenUsage[0].dataPoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: 8, + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'embeddings', + [ATTR_GEN_AI_REQUEST_MODEL]: 'text-embedding-3-small', + [ATTR_GEN_AI_TOKEN_TYPE]: 'input', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'text-embedding-3-small', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]) + ); + + const operationDuration = scopeMetrics.metrics.filter( + m => m.descriptor.name === 'gen_ai.client.operation.duration' + ); + expect(operationDuration.length).toBe(1); + expect(operationDuration[0].descriptor).toMatchObject({ + name: 'gen_ai.client.operation.duration', + type: 'HISTOGRAM', + description: 'GenAI operation duration', + unit: 's', + }); + expect(operationDuration[0].dataPoints.length).toBe(1); + expect(operationDuration[0].dataPoints).toEqual([ + expect.objectContaining({ + value: expect.objectContaining({ + sum: expect.any(Number), + }), + attributes: { + [ATTR_GEN_AI_SYSTEM]: 'openai', + [ATTR_GEN_AI_OPERATION_NAME]: 'embeddings', + [ATTR_GEN_AI_REQUEST_MODEL]: 'text-embedding-3-small', + [ATTR_GEN_AI_RESPONSE_MODEL]: 'text-embedding-3-small', + [ATTR_SERVER_ADDRESS]: 'api.openai.com', + [ATTR_SERVER_PORT]: 443, + }, + }), + ]); + expect( + (operationDuration[0].dataPoints[0].value as any).sum + ).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/instrumentation-openai/tsconfig.json b/packages/instrumentation-openai/tsconfig.json new file mode 100644 index 0000000000..4078877ce6 --- /dev/null +++ b/packages/instrumentation-openai/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": ".", + "outDir": "build" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/release-please-config.json b/release-please-config.json index 49902e8fb1..19fa95ede8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -57,6 +57,7 @@ "packages/instrumentation-mysql2": {}, "packages/instrumentation-nestjs-core": {}, "packages/instrumentation-net": {}, + "packages/instrumentation-openai": {}, "packages/instrumentation-oracledb": {}, "packages/instrumentation-pg": {}, "packages/instrumentation-pino": {},