From f747f6ed95935ffd10db60caae1a1a064591f265 Mon Sep 17 00:00:00 2001 From: Ara Date: Thu, 21 Nov 2024 06:13:44 +0530 Subject: [PATCH] Integrate OpenTelemetry tracing in Cody Webview (#6100) Enhance the chat application by integrating OpenTelemetry for tracing and context management. This update includes: - Added OpenTelemetry dependencies for context and tracing. - Implemented tracing spans in chat interactions, feedback submission in Webview - Managed active chat context using OpenTelemetry's context API. - Introduced a telemetry service for managing tracing configuration. - Added a new class for exporting trace data in `ChatController` from the webview These changes aim to improve observability and debugging capabilities by providing detailed trace information for chat interactions and operations ## Test plan - Run sourcegraph instance locally - Run `sg start otel ` - Run the debugger for vscode cody locally on this branch - Perform some chat operations - Go to `http://localhost:16686` to see if Jaegar is running - Select Cody-Client as the service - See a trace with the title `chat-interaction ` this is a trace coming from the webview image Here is a video https://www.loom.com/share/557b0ea9dffd4561810f8d67879f7dfb ## Changelog --- package.json | 2 + pnpm-lock.yaml | 153 +++++++----- vscode/src/chat/chat-view/ChatController.ts | 9 +- vscode/src/chat/protocol.ts | 5 + .../open-telemetry/CodyTraceExportWeb.ts | 191 +++++++++++++++ .../services/open-telemetry/trace-sender.ts | 46 ++++ vscode/webviews/App.tsx | 35 ++- vscode/webviews/Chat.tsx | 11 +- vscode/webviews/chat/Transcript.story.tsx | 1 + vscode/webviews/chat/Transcript.test.tsx | 1 + vscode/webviews/chat/Transcript.tsx | 225 +++++++++++++++--- vscode/webviews/utils/spanManager.ts | 137 +++++++++++ vscode/webviews/utils/telemetry.ts | 4 + .../utils/webviewOpenTelemetryService.ts | 100 ++++++++ web/lib/components/CodyWebChat.tsx | 6 +- 15 files changed, 828 insertions(+), 98 deletions(-) create mode 100644 vscode/src/services/open-telemetry/CodyTraceExportWeb.ts create mode 100644 vscode/src/services/open-telemetry/trace-sender.ts create mode 100644 vscode/webviews/utils/spanManager.ts create mode 100644 vscode/webviews/utils/webviewOpenTelemetryService.ts diff --git a/package.json b/package.json index 3da7a863c9cb..20603cd111e6 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ }, "dependencies": { "@openctx/client": "^0.0.30", + "@opentelemetry/sdk-trace-web": "^1.18.1", "@sourcegraph/telemetry": "^0.18.0", "observable-fns": "^0.6.1", "react": "18.2.0", @@ -78,6 +79,7 @@ }, "pnpm": { "overrides": { + "tslib": "2.1.0", "@lexical/react": "https://storage.googleapis.com/sourcegraph-assets/npm/lexical-react-sourcegraph-fork-31065486.tgz" }, "packageExtensions": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23e31526bdbc..6a2301bca45e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ neverBuiltDependencies: - playwright overrides: + tslib: 2.1.0 '@lexical/react': https://storage.googleapis.com/sourcegraph-assets/npm/lexical-react-sourcegraph-fork-31065486.tgz packageExtensionsChecksum: bc809394d179d8460f9d473fc54e379a @@ -20,6 +21,9 @@ importers: '@openctx/client': specifier: ^0.0.30 version: 0.0.30 + '@opentelemetry/sdk-trace-web': + specifier: ^1.18.1 + version: 1.18.1(@opentelemetry/api@1.7.0) '@sourcegraph/telemetry': specifier: ^0.18.0 version: 0.18.0 @@ -468,13 +472,13 @@ importers: version: 1.18.1(@opentelemetry/api@1.7.0) '@opentelemetry/sdk-trace-base': specifier: ^1.18.1 - version: 1.18.1(@opentelemetry/api@1.7.0) + version: 1.27.0(@opentelemetry/api@1.7.0) '@opentelemetry/sdk-trace-node': specifier: ^1.18.1 - version: 1.18.1(@opentelemetry/api@1.7.0) + version: 1.27.0(@opentelemetry/api@1.7.0) '@opentelemetry/semantic-conventions': specifier: ^1.18.1 - version: 1.18.1 + version: 1.27.0 '@radix-ui/react-accordion': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0) @@ -3473,7 +3477,7 @@ packages: '@microsoft/fast-element': 1.13.0 '@microsoft/fast-web-utilities': 5.4.1 tabbable: 5.3.3 - tslib: 1.14.1 + tslib: 2.1.0 dev: false /@microsoft/fast-react-wrapper@0.1.48(react@18.2.0): @@ -3637,11 +3641,11 @@ packages: resolution: {integrity: sha512-AdY5wvN0P2vXBi3b29hxZgSFvdhdxPB9+f0B6s//P9Q8nibRWeA3cHm8UmLpio9ABigkVHJ5NMPk+Mz8VCCyrw==} engines: {node: '>=8.0.0'} - /@opentelemetry/context-async-hooks@1.18.1(@opentelemetry/api@1.7.0): - resolution: {integrity: sha512-HHfJR32NH2x0b69CACCwH8m1dpNALoCTtpgmIWMNkeMGNUeKT48d4AX4xsF4uIRuUoRTbTgtSBRvS+cF97qwCQ==} + /@opentelemetry/context-async-hooks@1.27.0(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-CdZ3qmHCwNhFAzjTgHqrDQ44Qxcpz43cVxZRhOs+Ns/79ug+Mr84Bkb626bkJLkA3+BLimA5YAEVRlJC6pFb7g==} engines: {node: '>=14'} peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.8.0' + '@opentelemetry/api': '>=1.0.0 <1.10.0' dependencies: '@opentelemetry/api': 1.7.0 dev: false @@ -3656,6 +3660,16 @@ packages: '@opentelemetry/semantic-conventions': 1.18.1 dev: false + /@opentelemetry/core@1.27.0(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/semantic-conventions': 1.27.0 + dev: false + /@opentelemetry/exporter-trace-otlp-http@0.45.1(@opentelemetry/api@1.7.0): resolution: {integrity: sha512-a6CGqSG66n5R1mghzLMzyzn3iGap1b0v+0PjKFjfYuwLtpHQBxh2PHxItu+m2mXSwnM4R0GJlk9oUW5sQkCE0w==} engines: {node: '>=14'} @@ -3726,24 +3740,24 @@ packages: '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) dev: false - /@opentelemetry/propagator-b3@1.18.1(@opentelemetry/api@1.7.0): - resolution: {integrity: sha512-oSTUOsnt31JDx5SoEy27B5jE1/tiPvvE46w7CDKj0R5oZhCCfYH2bbSGa7NOOyDXDNqQDkgqU1DIV/xOd3f8pw==} + /@opentelemetry/propagator-b3@1.27.0(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-pTsko3gnMioe3FeWcwTQR3omo5C35tYsKKwjgTCTVCgd3EOWL9BZrMfgLBmszrwXABDfUrlAEFN/0W0FfQGynQ==} engines: {node: '>=14'} peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.8.0' + '@opentelemetry/api': '>=1.0.0 <1.10.0' dependencies: '@opentelemetry/api': 1.7.0 - '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/core': 1.27.0(@opentelemetry/api@1.7.0) dev: false - /@opentelemetry/propagator-jaeger@1.18.1(@opentelemetry/api@1.7.0): - resolution: {integrity: sha512-Kh4M1Qewv0Tbmts6D8LgNzx99IjdE18LCmY/utMkgVyU7Bg31Yuj+X6ZyoIRKPcD2EV4rVkuRI16WVMRuGbhWA==} + /@opentelemetry/propagator-jaeger@1.27.0(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-EI1bbK0wn0yIuKlc2Qv2LKBRw6LiUWevrjCF80fn/rlaB+7StAi8Y5s8DBqAYNpY7v1q86+NjU18v7hj2ejU3A==} engines: {node: '>=14'} peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.8.0' + '@opentelemetry/api': '>=1.0.0 <1.10.0' dependencies: '@opentelemetry/api': 1.7.0 - '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) + '@opentelemetry/core': 1.27.0(@opentelemetry/api@1.7.0) dev: false /@opentelemetry/resources@1.18.1(@opentelemetry/api@1.7.0): @@ -3757,6 +3771,17 @@ packages: '@opentelemetry/semantic-conventions': 1.18.1 dev: false + /@opentelemetry/resources@1.27.0(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.27.0(@opentelemetry/api@1.7.0) + '@opentelemetry/semantic-conventions': 1.27.0 + dev: false + /@opentelemetry/sdk-logs@0.45.1(@opentelemetry/api-logs@0.45.1)(@opentelemetry/api@1.7.0): resolution: {integrity: sha512-z0RRgW4LeKEKnhXS4F/HnqB6+7gsy63YK47F4XAJYHs4s1KKg8XnQ2RkbuL31i/a9nXkylttYtvsT50CGr487g==} engines: {node: '>=14'} @@ -3794,19 +3819,43 @@ packages: '@opentelemetry/semantic-conventions': 1.18.1 dev: false - /@opentelemetry/sdk-trace-node@1.18.1(@opentelemetry/api@1.7.0): - resolution: {integrity: sha512-ML0l9TNlfLoplLF1F8lb95NGKgdm6OezDS3Ymqav9sYxMd5bnH2LZVzd4xEF+ov5vpZJOGdWxJMs2nC9no7+xA==} + /@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-btz6XTQzwsyJjombpeqCX6LhiMQYpzt2pIYNPnw0IPO/3AhT6yjnf8Mnv3ZC2A4eRYOjqrg+bfaXg9XHDRJDWQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/core': 1.27.0(@opentelemetry/api@1.7.0) + '@opentelemetry/resources': 1.27.0(@opentelemetry/api@1.7.0) + '@opentelemetry/semantic-conventions': 1.27.0 + dev: false + + /@opentelemetry/sdk-trace-node@1.27.0(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-dWZp/dVGdUEfRBjBq2BgNuBlFqHCxyyMc8FsN0NX15X07mxSUO0SZRLyK/fdAVrde8nqFI/FEdMH4rgU9fqJfQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + dependencies: + '@opentelemetry/api': 1.7.0 + '@opentelemetry/context-async-hooks': 1.27.0(@opentelemetry/api@1.7.0) + '@opentelemetry/core': 1.27.0(@opentelemetry/api@1.7.0) + '@opentelemetry/propagator-b3': 1.27.0(@opentelemetry/api@1.7.0) + '@opentelemetry/propagator-jaeger': 1.27.0(@opentelemetry/api@1.7.0) + '@opentelemetry/sdk-trace-base': 1.27.0(@opentelemetry/api@1.7.0) + semver: 7.6.3 + dev: false + + /@opentelemetry/sdk-trace-web@1.18.1(@opentelemetry/api@1.7.0): + resolution: {integrity: sha512-WN30vxy4NY8TqFWuICXaPXjBdy6A5kDhxOqp4NfhqXfpcWWT0GqSgv05Q42quWYOFgaulnmPRRJwxzAdhBliLQ==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.8.0' dependencies: '@opentelemetry/api': 1.7.0 - '@opentelemetry/context-async-hooks': 1.18.1(@opentelemetry/api@1.7.0) '@opentelemetry/core': 1.18.1(@opentelemetry/api@1.7.0) - '@opentelemetry/propagator-b3': 1.18.1(@opentelemetry/api@1.7.0) - '@opentelemetry/propagator-jaeger': 1.18.1(@opentelemetry/api@1.7.0) '@opentelemetry/sdk-trace-base': 1.18.1(@opentelemetry/api@1.7.0) - semver: 7.6.0 + '@opentelemetry/semantic-conventions': 1.18.1 dev: false /@opentelemetry/semantic-conventions@1.18.1: @@ -3814,6 +3863,11 @@ packages: engines: {node: '>=14'} dev: false + /@opentelemetry/semantic-conventions@1.27.0: + resolution: {integrity: sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==} + engines: {node: '>=14'} + dev: false + /@opentelemetry/semantic-conventions@1.3.1: resolution: {integrity: sha512-wU5J8rUoo32oSef/rFpOT1HIjLjAv3qIDHkw1QIhODV3OpAVHi5oVzlouozg9obUmZKtbZ0qUe/m7FP0y0yBzA==} engines: {node: '>=8.12.0'} @@ -7183,7 +7237,7 @@ packages: esbuild: '>=0.10.0' dependencies: esbuild: 0.18.20 - tslib: 2.7.0 + tslib: 2.1.0 dev: true /@yarnpkg/fslib@2.10.3: @@ -7191,7 +7245,7 @@ packages: engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'} dependencies: '@yarnpkg/libzip': 2.3.0 - tslib: 1.14.1 + tslib: 2.1.0 dev: true /@yarnpkg/libzip@2.3.0: @@ -7199,7 +7253,7 @@ packages: engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'} dependencies: '@types/emscripten': 1.39.10 - tslib: 1.14.1 + tslib: 2.1.0 dev: true /@zkochan/rimraf@3.0.2: @@ -7425,7 +7479,7 @@ packages: resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} dependencies: - tslib: 2.7.0 + tslib: 2.1.0 dev: false /aria-query@5.1.3: @@ -7509,14 +7563,14 @@ packages: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} dependencies: - tslib: 2.7.0 + tslib: 2.1.0 dev: false /ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} dependencies: - tslib: 2.7.0 + tslib: 2.1.0 dev: true /astral-regex@2.0.0: @@ -7527,7 +7581,7 @@ packages: /async-mutex@0.4.0: resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==} dependencies: - tslib: 2.7.0 + tslib: 2.1.0 dev: false /async@3.2.5: @@ -9097,7 +9151,7 @@ packages: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.1.0 dev: true /dotenv-expand@10.0.0: @@ -9274,7 +9328,7 @@ packages: dependencies: esbuild: 0.18.20 find-up: 5.0.0 - tslib: 2.7.0 + tslib: 2.1.0 dev: true /esbuild-plugin-alias@0.2.1: @@ -11894,7 +11948,7 @@ packages: /lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} dependencies: - tslib: 2.7.0 + tslib: 2.1.0 dev: true /lowlight@2.9.0: @@ -12932,7 +12986,7 @@ packages: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 - tslib: 2.7.0 + tslib: 2.1.0 dev: true /nocache@3.0.4: @@ -14306,7 +14360,7 @@ packages: '@types/react': 18.2.37 react: 18.2.0 react-style-singleton: 2.2.1(@types/react@18.2.37)(react@18.2.0) - tslib: 2.7.0 + tslib: 2.1.0 dev: false /react-remove-scroll-bar@2.3.6(@types/react@18.2.79)(react@18.2.0): @@ -14322,7 +14376,7 @@ packages: '@types/react': 18.2.79 react: 18.2.0 react-style-singleton: 2.2.1(@types/react@18.2.79)(react@18.2.0) - tslib: 2.7.0 + tslib: 2.1.0 dev: false /react-remove-scroll@2.5.5(@types/react@18.2.37)(react@18.2.0): @@ -14339,7 +14393,7 @@ packages: react: 18.2.0 react-remove-scroll-bar: 2.3.6(@types/react@18.2.37)(react@18.2.0) react-style-singleton: 2.2.1(@types/react@18.2.37)(react@18.2.0) - tslib: 2.7.0 + tslib: 2.1.0 use-callback-ref: 1.3.2(@types/react@18.2.37)(react@18.2.0) use-sidecar: 1.1.2(@types/react@18.2.37)(react@18.2.0) dev: false @@ -14358,7 +14412,7 @@ packages: react: 18.2.0 react-remove-scroll-bar: 2.3.6(@types/react@18.2.79)(react@18.2.0) react-style-singleton: 2.2.1(@types/react@18.2.79)(react@18.2.0) - tslib: 2.7.0 + tslib: 2.1.0 use-callback-ref: 1.3.2(@types/react@18.2.79)(react@18.2.0) use-sidecar: 1.1.2(@types/react@18.2.79)(react@18.2.0) dev: false @@ -14377,7 +14431,7 @@ packages: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.2.0 - tslib: 2.7.0 + tslib: 2.1.0 dev: false /react-style-singleton@2.2.1(@types/react@18.2.79)(react@18.2.0): @@ -14394,7 +14448,7 @@ packages: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.2.0 - tslib: 2.7.0 + tslib: 2.1.0 dev: false /react@18.2.0: @@ -14504,7 +14558,7 @@ packages: esprima: 4.0.1 source-map: 0.6.1 tiny-invariant: 1.3.3 - tslib: 2.7.0 + tslib: 2.1.0 dev: true /redent@3.0.0: @@ -14872,7 +14926,7 @@ packages: /rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} dependencies: - tslib: 2.7.0 + tslib: 2.1.0 dev: true /safe-buffer@5.1.2: @@ -15139,7 +15193,7 @@ packages: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} dependencies: dot-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.1.0 dev: true /snapdragon-node@2.1.1: @@ -16117,15 +16171,8 @@ packages: strip-bom: 3.0.0 dev: true - /tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - /tslib@2.1.0: resolution: {integrity: sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==} - dev: false - - /tslib@2.7.0: - resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} /tsscmp@1.0.6: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} @@ -16526,7 +16573,7 @@ packages: dependencies: '@types/react': 18.2.37 react: 18.2.0 - tslib: 2.7.0 + tslib: 2.1.0 dev: false /use-callback-ref@1.3.2(@types/react@18.2.79)(react@18.2.0): @@ -16541,7 +16588,7 @@ packages: dependencies: '@types/react': 18.2.79 react: 18.2.0 - tslib: 2.7.0 + tslib: 2.1.0 dev: false /use-sidecar@1.1.2(@types/react@18.2.37)(react@18.2.0): @@ -16557,7 +16604,7 @@ packages: '@types/react': 18.2.37 detect-node-es: 1.1.0 react: 18.2.0 - tslib: 2.7.0 + tslib: 2.1.0 dev: false /use-sidecar@1.1.2(@types/react@18.2.79)(react@18.2.0): @@ -16573,7 +16620,7 @@ packages: '@types/react': 18.2.79 detect-node-es: 1.1.0 react: 18.2.0 - tslib: 2.7.0 + tslib: 2.1.0 dev: false /use@3.1.1: @@ -17323,7 +17370,7 @@ packages: dev: false '@storage.googleapis.com/sourcegraph-assets/npm/lexical-react-sourcegraph-fork-31065486.tgz(react-dom@18.2.0)(react@18.2.0)(yjs@13.6.19)': - resolution: {tarball: https://storage.googleapis.com/sourcegraph-assets/npm/lexical-react-sourcegraph-fork-31065486.tgz} + resolution: {registry: https://registry.npmjs.org/, tarball: https://storage.googleapis.com/sourcegraph-assets/npm/lexical-react-sourcegraph-fork-31065486.tgz} id: '@storage.googleapis.com/sourcegraph-assets/npm/lexical-react-sourcegraph-fork-31065486.tgz' name: '@lexical/react' version: 0.17.0 diff --git a/vscode/src/chat/chat-view/ChatController.ts b/vscode/src/chat/chat-view/ChatController.ts index af1ce59a3049..b4eaca15639d 100644 --- a/vscode/src/chat/chat-view/ChatController.ts +++ b/vscode/src/chat/chat-view/ChatController.ts @@ -16,9 +16,6 @@ import { skip, skipPendingOperation, } from '@sourcegraph/cody-shared' -import * as uuid from 'uuid' -import * as vscode from 'vscode' - import { type BillingCategory, type BillingProduct, @@ -77,6 +74,8 @@ import { truncatePromptString, userProductSubscription, } from '@sourcegraph/cody-shared' +import * as uuid from 'uuid' +import * as vscode from 'vscode' import type { Span } from '@opentelemetry/api' import { captureException } from '@sentry/core' @@ -106,6 +105,7 @@ import { publicRepoMetadataIfAllWorkspaceReposArePublic } from '../../repository import { authProvider } from '../../services/AuthProvider' import { AuthProviderSimplified } from '../../services/AuthProviderSimplified' import { localStorage } from '../../services/LocalStorageProvider' +import { TraceSender } from '../../services/open-telemetry/trace-sender' import { recordExposedExperimentsToSpan } from '../../services/open-telemetry/utils' import { handleCodeFromInsertAtCursor, @@ -325,6 +325,9 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv message.fileName ) break + case 'trace-export': + TraceSender.send(message.traceSpanEncodedJson) + break case 'smartApplyAccept': await vscode.commands.executeCommand('cody.fixup.codelens.accept', message.id) break diff --git a/vscode/src/chat/protocol.ts b/vscode/src/chat/protocol.ts index 615e4e136de1..6b47e370fd48 100644 --- a/vscode/src/chat/protocol.ts +++ b/vscode/src/chat/protocol.ts @@ -97,6 +97,11 @@ export type WebviewMessage = instruction?: string | undefined | null fileName?: string | undefined | null } + | { + command: 'trace-export' + // The traceSpan is a JSON-encoded string representing the trace data. + traceSpanEncodedJson: string + } | { command: 'smartApplyAccept' id: FixupTaskID diff --git a/vscode/src/services/open-telemetry/CodyTraceExportWeb.ts b/vscode/src/services/open-telemetry/CodyTraceExportWeb.ts new file mode 100644 index 000000000000..1a9326152009 --- /dev/null +++ b/vscode/src/services/open-telemetry/CodyTraceExportWeb.ts @@ -0,0 +1,191 @@ +import type { ExportResult } from '@opentelemetry/core' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import type { CodyIDE } from '@sourcegraph/cody-shared/src/configuration' +import { getVSCodeAPI } from '../../../webviews/utils/VSCodeApi' +import { logDebug } from '../../../webviews/utils/logger' + +const MAX_TRACE_RETAIN_MS = 60 * 1000 * 5 // 5 minutes + +// Exports spans as JSON to the extension host so that it can be sent to the OTel collector on the SG instance +export class CodyTraceExporterWeb extends OTLPTraceExporter { + private isTracingEnabled: boolean + private queuedSpans: Map = new Map() + private clientPlatform: CodyIDE + private agentVersion?: string + private lastExpiryCheck = 0 + + constructor({ + isTracingEnabled, + clientPlatform, + agentVersion, + }: { isTracingEnabled: boolean; clientPlatform: CodyIDE; agentVersion?: string }) { + super({ + httpAgentOptions: { + rejectUnauthorized: false, + }, + headers: { + 'Content-Type': 'application/json', + }, + }) + this.isTracingEnabled = isTracingEnabled + this.clientPlatform = clientPlatform + this.agentVersion = agentVersion + } + + private removeExpiredSpans(now: number): void { + for (const [spanId, { enqueuedAt }] of this.queuedSpans.entries()) { + if (now - enqueuedAt > MAX_TRACE_RETAIN_MS) { + this.queuedSpans.delete(spanId) + logDebug('[CodyTraceExporterWeb] Removed expired span from queue:', spanId) + } + } + } + + export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { + if (!this.isTracingEnabled) { + return + } + + const now = performance.now() + if (now - this.lastExpiryCheck > MAX_TRACE_RETAIN_MS) { + this.removeExpiredSpans(now) + this.lastExpiryCheck = now + } + + // Include queued spans for re-evaluation + const allSpans = [...spans, ...Array.from(this.queuedSpans.values()).map(q => q.span)] + for (const span of allSpans) { + span.attributes.clientPlatform = this.clientPlatform + span.attributes.agentVersion = this.agentVersion + } + + // Build span hierarchy map + const spanMap = new Map() + const spansByRoot = new Map>() + + // First, map all spans by their ID + for (const span of allSpans) { + spanMap.set(span.spanContext().spanId, span) + } + + // Group spans by their root span + for (const span of spanMap.values()) { + const rootSpan = getRootSpan(spanMap, span) + if (rootSpan) { + const rootId = rootSpan.spanContext().spanId + if (!spansByRoot.has(rootId)) { + spansByRoot.set(rootId, new Set()) + } + spansByRoot.get(rootId)?.add(span) + } else { + // Queue spans without a root for later + const spanId = span.spanContext().spanId + if (!this.queuedSpans.has(spanId)) { + this.queuedSpans.set(spanId, { span, enqueuedAt: now }) + } + } + } + + const spansToExport: ReadableSpan[] = [] + + // Process each group of spans + for (const [rootId, spanGroup] of spansByRoot.entries()) { + const rootSpan = spanMap.get(rootId) + if (!rootSpan || !isSampled(rootSpan)) { + continue + } + + // Add all spans from complete groups + spansToExport.push(...spanGroup) + + // Remove these spans from queued spans if present + for (const span of spanGroup) { + this.queuedSpans.delete(span.spanContext().spanId) + logDebug('[CodyTraceExporterWeb] Removed span from queue:', span.spanContext().spanId) + } + } + if (spansToExport.length > 0) { + super.export(spansToExport, resultCallback) + } + } + + send(spans: ReadableSpan[]): void { + try { + const exportData = this.convert(spans) + + logDebug( + '[CodyTraceExporterWeb] Exporting spans', + JSON.stringify({ + count: spans.length, + rootSpans: spans.filter(s => !s.parentSpanId).length, + renderSpans: spans.filter(s => s.name === 'assistant-message-render').length, + }) + ) + + // Validate and clean the export data before sending + const messageData = { + resourceSpans: (exportData.resourceSpans ?? []).map(span => ({ + ...span, + resource: { + ...span?.resource, + attributes: + span?.resource?.attributes?.map(attr => ({ + key: attr.key, + value: attr.value, + })) ?? [], + }, + })), + timestamp: performance.now(), + } + + // Send the validated and cleaned data + getVSCodeAPI().postMessage({ + command: 'trace-export', + traceSpanEncodedJson: JSON.stringify(messageData, getCircularReplacer()), + }) + } catch (error) { + console.error('[CodyTraceExporterWeb] Error exporting spans:', error) + } + } +} + +function getRootSpan(spanMap: Map, span: ReadableSpan): ReadableSpan | null { + // Start with the input span + let currentSpan = span + + while (true) { + // If we find a span without a parent, it's the root + if (!currentSpan.parentSpanId) { + return currentSpan + } + + const parentSpan = spanMap.get(currentSpan.parentSpanId) + + // Return null if parent ID exists but parent span not found. + // These spans are expected to be completed later. + if (!parentSpan) { + return null + } + + currentSpan = parentSpan + } +} + +function isSampled(rootSpan: ReadableSpan): boolean { + return rootSpan.attributes.sampled === true +} + +// Helper function to handle circular references in JSON serialization +function getCircularReplacer() { + const seen = new WeakSet() + return (key: string, value: any) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return + } + seen.add(value) + } + return value + } +} diff --git a/vscode/src/services/open-telemetry/trace-sender.ts b/vscode/src/services/open-telemetry/trace-sender.ts new file mode 100644 index 000000000000..d7e7bb410d82 --- /dev/null +++ b/vscode/src/services/open-telemetry/trace-sender.ts @@ -0,0 +1,46 @@ +import { currentResolvedConfig } from '@sourcegraph/cody-shared' +import fetch from 'node-fetch' +import { logDebug, logError } from '../../output-channel-logger' + +/** + * Sends trace data to the server without blocking + */ +export const TraceSender = { + send(spanData: any): void { + // Don't await - let it run in background, but do handle errors + void doSendTraceData(spanData).catch(error => { + logError('TraceSender', `Error sending trace data: ${error}`) + }) + }, +} + +/** + * Sends trace data to the server using the provided span data as a json string + * that comes from the webview. It retrieves the current resolved configuration to obtain + * authentication details and constructs the trace URL. It sends a POST + * request with the span data as the body. + */ +async function doSendTraceData(spanData: any): Promise { + const { auth } = await currentResolvedConfig() + if (!auth.accessToken) { + logError('TraceSender', 'Cannot send trace data: not authenticated') + throw new Error('Not authenticated') + } + + const traceUrl = new URL('/-/debug/otlp/v1/traces', auth.serverEndpoint).toString() + const response = await fetch(traceUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(auth.accessToken ? { Authorization: `token ${auth.accessToken}` } : {}), + }, + body: spanData, + }) + + if (!response.ok) { + logError('TraceSender', `Failed to send trace data: ${response.statusText}`) + throw new Error(`Failed to send trace data: ${response.statusText}`) + } + + logDebug('TraceSender', 'Trace data sent successfully') +} diff --git a/vscode/webviews/App.tsx b/vscode/webviews/App.tsx index 0de766745f03..8728a26bb0f1 100644 --- a/vscode/webviews/App.tsx +++ b/vscode/webviews/App.tsx @@ -1,7 +1,5 @@ import { type ComponentProps, useCallback, useEffect, useMemo, useState } from 'react' -import styles from './App.module.css' - import { type ChatMessage, type DefaultContext, @@ -10,9 +8,11 @@ import { type TelemetryRecorder, } from '@sourcegraph/cody-shared' import type { AuthMethod } from '../src/chat/protocol' +import styles from './App.module.css' import { AuthPage } from './AuthPage' import { LoadingPage } from './LoadingPage' import { useClientActionDispatcher } from './client/clientState' +import { WebviewOpenTelemetryService } from './utils/webviewOpenTelemetryService' import { ExtensionAPIProviderFromVSCodeAPI } from '@sourcegraph/prompt-editor' import { CodyPanel } from './CodyPanel' @@ -20,7 +20,11 @@ import { View } from './tabs' import type { VSCodeWrapper } from './utils/VSCodeApi' import { ComposedWrappers, type Wrapper } from './utils/composeWrappers' import { updateDisplayPathEnvInfoForWebview } from './utils/displayPathEnvInfo' -import { TelemetryRecorderContext, createWebviewTelemetryRecorder } from './utils/telemetry' +import { + TelemetryRecorderContext, + WebviewTelemetryServiceContext, + createWebviewTelemetryRecorder, +} from './utils/telemetry' import { type Config, ConfigProvider } from './utils/useConfig' export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vscodeAPI }) => { @@ -152,9 +156,24 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc // V2 telemetry recorder const telemetryRecorder = useMemo(() => createWebviewTelemetryRecorder(vscodeAPI), [vscodeAPI]) + const webviewTelemetryService = useMemo(() => { + const service = WebviewOpenTelemetryService.getInstance() + return service + }, []) + useEffect(() => { + if (config) { + webviewTelemetryService.configure({ + isTracingEnabled: true, + debugVerbose: true, + agentIDE: config.clientCapabilities.agentIDE, + extensionAgentVersion: config.clientCapabilities.agentExtensionVersion, + }) + } + }, [config, webviewTelemetryService]) + const wrappers = useMemo( - () => getAppWrappers({ vscodeAPI, telemetryRecorder, config }), - [vscodeAPI, telemetryRecorder, config] + () => getAppWrappers({ vscodeAPI, telemetryRecorder, config, webviewTelemetryService }), + [vscodeAPI, telemetryRecorder, config, webviewTelemetryService] ) // Wait for all the data to be loaded before rendering Chat View @@ -198,6 +217,7 @@ interface GetAppWrappersOptions { telemetryRecorder: TelemetryRecorder config: Config | null staticDefaultContext?: DefaultContext + webviewTelemetryService: WebviewOpenTelemetryService } export function getAppWrappers({ @@ -205,6 +225,7 @@ export function getAppWrappers({ telemetryRecorder, config, staticDefaultContext, + webviewTelemetryService, }: GetAppWrappersOptions): Wrapper[] { return [ { @@ -219,5 +240,9 @@ export function getAppWrappers({ component: ConfigProvider, props: { value: config }, } satisfies Wrapper>, + { + provider: WebviewTelemetryServiceContext.Provider, + value: webviewTelemetryService, + } satisfies Wrapper, ] } diff --git a/vscode/webviews/Chat.tsx b/vscode/webviews/Chat.tsx index 502413f352dd..78ae80c9d7b5 100644 --- a/vscode/webviews/Chat.tsx +++ b/vscode/webviews/Chat.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AuthenticatedAuthStatus, @@ -12,6 +12,7 @@ import type { import { Transcript, focusLastHumanMessageEditor } from './chat/Transcript' import type { VSCodeWrapper } from './utils/VSCodeApi' +import type { Context } from '@opentelemetry/api' import { truncateTextStart } from '@sourcegraph/cody-shared/src/prompt/truncation' import { CHAT_INPUT_TOKEN_BUDGET } from '@sourcegraph/cody-shared/src/token/constants' import styles from './Chat.module.css' @@ -21,7 +22,6 @@ import { ScrollDown } from './components/ScrollDown' import type { View } from './tabs' import { useTelemetryRecorder } from './utils/telemetry' import { useUserAccountInfo } from './utils/useConfig' - interface ChatboxProps { chatEnabled: boolean messageInProgress: ChatMessage | null @@ -64,6 +64,7 @@ export const Chat: React.FunctionComponent thumbsUp = 1, thumbsDown = 0, } + telemetryRecorder.recordEvent('cody.feedback', 'submit', { metadata: { feedbackType: text === 'thumbsUp' ? FeedbackType.thumbsUp : FeedbackType.thumbsDown, @@ -92,8 +93,10 @@ export const Chat: React.FunctionComponent (text: string, eventType: 'Button' | 'Keydown' = 'Button') => { const op = 'copy' // remove the additional /n added by the text area at the end of the text + const code = eventType === 'Button' ? text.replace(/\n$/, '') : text // Log the event type and text to telemetry in chat view + vscodeAPI.postMessage({ command: op, eventType, @@ -108,6 +111,7 @@ export const Chat: React.FunctionComponent return (text: string, newFile = false) => { const op = newFile ? 'newFile' : 'insert' // Log the event type and text to telemetry in chat view + vscodeAPI.postMessage({ command: op, // remove the additional /n added by the text area at the end of the text @@ -209,6 +213,7 @@ export const Chat: React.FunctionComponent focusLastHumanMessageEditor() }, [transcript]) + const [activeChatContext, setActiveChatContext] = useState() return ( <> @@ -218,6 +223,8 @@ export const Chat: React.FunctionComponent )} = { postMessage: () => {}, chatEnabled: true, models: mockedModels, + setActiveChatContext: () => {}, } satisfies ComponentProps, decorators: [ diff --git a/vscode/webviews/chat/Transcript.test.tsx b/vscode/webviews/chat/Transcript.test.tsx index 25ca14a1d7cb..7f6990a51119 100644 --- a/vscode/webviews/chat/Transcript.test.tsx +++ b/vscode/webviews/chat/Transcript.test.tsx @@ -23,6 +23,7 @@ const PROPS: Omit, 'transcript'> = { chatEnabled: true, postMessage: () => {}, models: MOCK_MODELS, + setActiveChatContext: () => {}, } vi.mock('@vscode/webview-ui-toolkit/react', () => ({ diff --git a/vscode/webviews/chat/Transcript.tsx b/vscode/webviews/chat/Transcript.tsx index 0f3ee527d5ee..84549055f82e 100644 --- a/vscode/webviews/chat/Transcript.tsx +++ b/vscode/webviews/chat/Transcript.tsx @@ -23,15 +23,18 @@ import { type MutableRefObject, memo, useCallback, + useEffect, useImperativeHandle, useMemo, useRef, + useState, } from 'react' import { URI } from 'vscode-uri' import type { UserAccountInfo } from '../Chat' import type { ApiPostMessage } from '../Chat' import { Button } from '../components/shadcn/ui/button' import { getVSCodeAPI } from '../utils/VSCodeApi' +import { SpanManager } from '../utils/spanManager' import { useTelemetryRecorder } from '../utils/telemetry' import { useExperimentalOneBox } from '../utils/useExperimentalOneBox' import type { CodeBlockActionsProps } from './ChatMessageContent/ChatMessageContent' @@ -48,13 +51,15 @@ import { HumanMessageCell } from './cells/messageCell/human/HumanMessageCell' import { CodyIcon } from './components/CodyIcon' import { InfoMessage } from './components/InfoMessage' +import { type Context, type Span, context, trace } from '@opentelemetry/api' interface TranscriptProps { + activeChatContext?: Context + setActiveChatContext: (context: Context | undefined) => void chatEnabled: boolean transcript: ChatMessage[] models: Model[] userInfo: UserAccountInfo messageInProgress: ChatMessage | null - guardrails?: Guardrails postMessage?: ApiPostMessage @@ -67,6 +72,8 @@ interface TranscriptProps { export const Transcript: FC = props => { const { + activeChatContext, + setActiveChatContext, chatEnabled, transcript, models, @@ -130,6 +137,8 @@ export const Transcript: FC = props => { {interactions.map((interaction, i) => ( { + activeChatContext: Context | undefined + setActiveChatContext: (context: Context | undefined) => void interaction: Interaction isFirstInteraction: boolean isLastInteraction: boolean @@ -251,7 +262,6 @@ const TranscriptInteraction: FC = memo(props => { editorRef: parentEditorRef, onAddToFollowupChat, } = props - const [intentResults, setIntentResults] = useMutatedValue< | { intent: ChatMessage['intent'] @@ -261,58 +271,100 @@ const TranscriptInteraction: FC = memo(props => { | null >() + const { activeChatContext, setActiveChatContext } = props const humanEditorRef = useRef(null) useImperativeHandle(parentEditorRef, () => humanEditorRef.current) - const onEditSubmit = useCallback( - (editorValue: SerializedPromptEditorValue, intentFromSubmit?: ChatMessage['intent']): void => { + const onUserAction = (action: 'edit' | 'submit', intentFromSubmit?: ChatMessage['intent']) => { + // Start the span as soon as the user initiates the action + const startMark = performance.mark('startSubmit') + const spanManager = new SpanManager('cody-webview') + const span = spanManager.startSpan('chat-interaction', { + attributes: { + sampled: true, + 'render.state': 'started', + 'startSubmit.mark': startMark.startTime, + }, + }) + + if (!span) { + throw new Error('Failed to start span for chat interaction') + } + + const spanContext = trace.setSpan(context.active(), span) + setActiveChatContext(spanContext) + + // Serialize the editor value after starting the span + const editorValue = humanEditorRef.current?.getSerializedValue() + if (!editorValue) { + console.error('Failed to serialize editor value') + return + } + + const commonProps = { + editorValue, + intent: intentFromSubmit || intentResults.current?.intent, + intentScores: intentFromSubmit ? undefined : intentResults.current?.allScores, + manuallySelectedIntent: !!intentFromSubmit, + } + + if (action === 'edit') { editHumanMessage({ messageIndexInTranscript: humanMessage.index, - editorValue, - intent: intentFromSubmit || intentResults.current?.intent, - intentScores: intentFromSubmit ? undefined : intentResults.current?.allScores, - manuallySelectedIntent: !!intentFromSubmit, + ...commonProps, }) + } else { + submitHumanMessage({ + ...commonProps, + }) + } + } + + const onEditSubmit = useCallback( + (intentFromSubmit?: ChatMessage['intent']): void => { + onUserAction('edit', intentFromSubmit) }, - [humanMessage.index, intentResults] + [onUserAction] ) const onFollowupSubmit = useCallback( - (editorValue: SerializedPromptEditorValue, intentFromSubmit?: ChatMessage['intent']): void => { - submitHumanMessage({ - editorValue, - intent: intentFromSubmit || intentResults.current?.intent, - intentScores: intentFromSubmit ? undefined : intentResults.current?.allScores, - manuallySelectedIntent: !!intentFromSubmit, - }) + (intentFromSubmit?: ChatMessage['intent']): void => { + onUserAction('submit', intentFromSubmit) }, - [intentResults] + [onUserAction] ) const extensionAPI = useExtensionAPI() const experimentalOneBoxEnabled = useExperimentalOneBox() const onChange = useMemo(() => { return debounce(async (editorValue: SerializedPromptEditorValue) => { - setIntentResults(undefined) - if (!experimentalOneBoxEnabled) { return } - // Only detect intent if a repository is mentioned if ( - editorValue.contextItems.find(contextItem => + !editorValue.contextItems.find(contextItem => ['repository', 'tree'].includes(contextItem.type) ) ) { - extensionAPI - .detectIntent( - inputTextWithoutContextChipsFromPromptEditorState(editorValue.editorState) - ) - .subscribe(value => { - setIntentResults(value) - }) + return } + + setIntentResults(undefined) + + const subscription = extensionAPI + .detectIntent(inputTextWithoutContextChipsFromPromptEditorState(editorValue.editorState)) + .subscribe({ + next: value => { + setIntentResults(value) + }, + error: error => { + console.error('Error detecting intent:', error) + }, + }) + + // Clean up subscription if component unmounts + return () => subscription.unsubscribe() }, 300) }, [experimentalOneBoxEnabled, extensionAPI, setIntentResults]) @@ -327,6 +379,112 @@ const TranscriptInteraction: FC = memo(props => { isLastSentInteraction && assistantMessage?.text === undefined ) + const spanManager = new SpanManager('cody-webview') + const renderSpan = useRef() + const timeToFirstTokenSpan = useRef() + const hasRecordedFirstToken = useRef(false) + + const [isLoading, setIsLoading] = useState(assistantMessage?.isLoading) + + useEffect(() => { + setIsLoading(assistantMessage?.isLoading) + }, [assistantMessage]) + + useEffect(() => { + if (!assistantMessage) return + + const startRenderSpan = () => { + // Reset the spans to their initial state + renderSpan.current = undefined + timeToFirstTokenSpan.current = undefined + hasRecordedFirstToken.current = false + + const startRenderMark = performance.mark('startRender') + // Start a new span for rendering the assistant message + renderSpan.current = spanManager.startSpan('assistant-message-render', { + attributes: { + sampled: true, + 'message.index': assistantMessage.index, + 'render.start_time': startRenderMark.startTime, + 'parent.span.id': activeChatContext + ? trace.getSpan(activeChatContext)?.spanContext().spanId + : undefined, + }, + context: activeChatContext, + }) + // Start a span to measure time to first token + timeToFirstTokenSpan.current = spanManager.startSpan('time-to-first-token', { + attributes: { 'message.index': assistantMessage.index }, + context: activeChatContext, + }) + } + + const endRenderSpan = () => { + // Mark the end of rendering + performance.mark('endRender') + // Measure the duration of the render + const measure = performance.measure('renderDuration', 'startRender', 'endRender') + if (renderSpan.current && measure.duration > 0) { + // Set attributes and end the render span + renderSpan.current.setAttributes({ + 'render.success': !assistantMessage?.error, + 'message.length': assistantMessage?.text?.length ?? 0, + 'render.total_time': measure.duration, + }) + renderSpan.current.end() + } + renderSpan.current = undefined + hasRecordedFirstToken.current = false + + if (activeChatContext) { + const rootSpan = trace.getSpan(activeChatContext) + if (rootSpan) { + // Calculate and set the total chat time + const chatTotalTime = + performance.now() - performance.getEntriesByName('startSubmit')[0].startTime + rootSpan.setAttributes({ + 'chat.completed': true, + 'render.state': 'completed', + 'chat.total_time': chatTotalTime, + }) + rootSpan.end() + } + } + // Clear the active chat context + setActiveChatContext(undefined) + } + + const endFirstTokenSpan = () => { + if (renderSpan.current && timeToFirstTokenSpan.current) { + // Mark the first token + performance.mark('firstToken') + // Measure the time to first token + performance.measure('timeToFirstToken', 'startRender', 'firstToken') + const firstTokenMeasure = performance.getEntriesByName('timeToFirstToken')[0] + if (firstTokenMeasure.duration > 0) { + // Set attributes and end the time-to-first-token span + timeToFirstTokenSpan.current.setAttributes({ + 'time.to.first.token': firstTokenMeasure.duration, + }) + timeToFirstTokenSpan.current.end() + timeToFirstTokenSpan.current = undefined + hasRecordedFirstToken.current = true + } + } + } + // Case 3: End the time-to-first-token span when the first token appears + if (assistantMessage.text && !hasRecordedFirstToken.current && timeToFirstTokenSpan.current) { + endFirstTokenSpan() + } + // Case 1: Start rendering if the assistant message is loading and no render span exists + if (assistantMessage.isLoading && !renderSpan.current && activeChatContext) { + context.with(activeChatContext, startRenderSpan) + } + // Case 2: End rendering if loading is complete and a render span exists + else if (!isLoading && renderSpan.current) { + endRenderSpan() + } + }, [assistantMessage, activeChatContext, setActiveChatContext, spanManager, isLoading]) const humanMessageInfo = useMemo(() => { // See SRCH-942: it's critical to memoize this value to avoid repeated @@ -342,7 +500,7 @@ const TranscriptInteraction: FC = memo(props => { (intent: ChatMessage['intent']) => { const editorState = humanEditorRef.current?.getSerializedValue() if (editorState) { - onEditSubmit(editorState, intent) + onEditSubmit(intent) telemetryRecorder.recordEvent('onebox.intentCorrection', 'clicked', { metadata: { recordsPrivateMetadataTranscript: 1, @@ -367,10 +525,7 @@ const TranscriptInteraction: FC = memo(props => { return } await editor.addMentions(corpusContextItems, 'before', ' ') - const newEditorState = humanEditorRef.current?.getSerializedValue() - if (newEditorState) { - onEditSubmit(newEditorState, 'chat') - } + onEditSubmit('chat') } }, [corpusContextItems, onEditSubmit]) @@ -408,7 +563,9 @@ const TranscriptInteraction: FC = memo(props => { isSent={!humanMessage.isUnsentFollowup} isPendingPriorResponse={priorAssistantMessageIsLoading} onChange={onChange} - onSubmit={humanMessage.isUnsentFollowup ? onFollowupSubmit : onEditSubmit} + onSubmit={ + humanMessage.isUnsentFollowup ? () => onFollowupSubmit() : () => onEditSubmit() + } onStop={onStop} isFirstInteraction={isFirstInteraction} isLastInteraction={isLastInteraction} diff --git a/vscode/webviews/utils/spanManager.ts b/vscode/webviews/utils/spanManager.ts new file mode 100644 index 000000000000..9841644e6f36 --- /dev/null +++ b/vscode/webviews/utils/spanManager.ts @@ -0,0 +1,137 @@ +import { + type Attributes, + type Context, + type Span, + type SpanOptions, + SpanStatusCode, + type Tracer, + context, + trace, +} from '@opentelemetry/api' + +// Extend SpanOptions to optionally include context +type SpanManagerOptions = SpanOptions & { + context?: Context +} + +/** + * SpanManager is responsible for managing the lifecycle of spans used in tracing. + * It provides methods to start, end, and manage spans, as well as to handle context propagation. + * + * Features: + * - Start and manage active spans with context propagation. + * - End spans and record exceptions. + * - Set attributes on spans. + * - Clear all spans and reset the active context. + */ +export class SpanManager { + private spans = new Map() + private endedSpans = new Set() + private tracer: Tracer + private activeContext?: Context + + constructor(tracerName = 'cody-webview') { + this.tracer = trace.getTracer(tracerName) + } + + startActiveSpan( + name: string, + optionsOrFn: SpanManagerOptions | ((span: Span) => Promise | T), + fnOrUndefined?: (span: Span) => Promise | T + ): Promise { + const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn + const fn = typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined + + if (!fn) { + throw new Error('No callback function provided to startActiveSpan') + } + + // Context is optional - if not provided, use active context + const parentContext = options.context || this.activeContext || context.active() + + // Extract standard SpanOptions from SpanManagerOptions + const spanOptions: SpanOptions = { + attributes: options.attributes, + kind: options.kind, + links: options.links, + startTime: options.startTime, + } + + return this.tracer.startActiveSpan(name, spanOptions, async span => { + this.spans.set(name, span) + + // Create new context with this span + const spanContext = trace.setSpan(parentContext, span) + this.activeContext = spanContext + + try { + return await context.with(spanContext, () => fn(span)) + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : 'Unknown error', + }) + span.recordException(error as Error) + throw error + } finally { + this.endSpan(name) + } + }) + } + + startSpan(name: string, options?: SpanManagerOptions): Span | undefined { + if (this.spans.has(name)) { + return this.spans.get(name) + } + + // Use provided context or fall back to active context + const parentContext = options?.context || this.activeContext || context.active() + + // Extract standard SpanOptions from SpanManagerOptions + const spanOptions: SpanOptions = { + attributes: options?.attributes, + kind: options?.kind, + links: options?.links, + startTime: options?.startTime, + } + + const span = this.tracer.startSpan(name, spanOptions, parentContext) + this.spans.set(name, span) + return span + } + + getActiveContext(): Context | undefined { + return this.activeContext + } + + setActiveContext(ctx: Context): void { + this.activeContext = ctx + } + + endSpan(name: string): void { + const span = this.spans.get(name) + if (span && !this.endedSpans.has(name)) { + span.end() + this.endedSpans.add(name) + this.spans.delete(name) + } + } + + setSpanAttributes(name: string, attributes: Record): void { + const span = this.spans.get(name) + if (span && !this.endedSpans.has(name)) { + span.setAttributes(attributes as Attributes) + } + } + + endAllSpans(): void { + this.spans.forEach((_, name) => this.endSpan(name)) + } + + clear(): void { + this.endAllSpans() + this.spans.clear() + this.endedSpans.clear() + this.activeContext = undefined + } +} diff --git a/vscode/webviews/utils/telemetry.ts b/vscode/webviews/utils/telemetry.ts index 232d5f8723d4..f403474c7d87 100644 --- a/vscode/webviews/utils/telemetry.ts +++ b/vscode/webviews/utils/telemetry.ts @@ -4,6 +4,7 @@ import { createContext, useContext } from 'react' import type { WebviewRecordEventParameters } from '../../src/chat/protocol' import type { ApiPostMessage } from '../Chat' import type { VSCodeWrapper } from './VSCodeApi' +import { WebviewOpenTelemetryService } from './webviewOpenTelemetryService' /** * Create a new {@link TelemetryRecorder} for use in the VS Code webviews for V2 telemetry. @@ -27,6 +28,9 @@ export function createWebviewTelemetryRecorder( }, } } +export const WebviewTelemetryServiceContext = createContext( + new WebviewOpenTelemetryService() +) export const TelemetryRecorderContext = createContext(null) diff --git a/vscode/webviews/utils/webviewOpenTelemetryService.ts b/vscode/webviews/utils/webviewOpenTelemetryService.ts new file mode 100644 index 000000000000..ca73234b90c6 --- /dev/null +++ b/vscode/webviews/utils/webviewOpenTelemetryService.ts @@ -0,0 +1,100 @@ +import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api' +import { Resource } from '@opentelemetry/resources' +import { BatchSpanProcessor, WebTracerProvider } from '@opentelemetry/sdk-trace-web' +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' +import type { CodyIDE } from '@sourcegraph/cody-shared/src/configuration' +import { CodyTraceExporterWeb } from '../../src/services/open-telemetry/CodyTraceExportWeb' + +// This class is used to initialize and manage the OpenTelemetry service for the webview. +// Its inspired by the OpenTelemetryService class in the node extension. +// It is used to initialize the tracer provider and add a span processor that exports the spans to the webview. +export class WebviewOpenTelemetryService { + private static instance: WebviewOpenTelemetryService | null = null + private tracerProvider?: WebTracerProvider + private unloadInstrumentations?: () => void + private isTracingEnabled = false + private isInitialized = false + private agentIDE?: CodyIDE + private extensionAgentVersion?: string + constructor() { + if (!WebviewOpenTelemetryService.instance) { + WebviewOpenTelemetryService.instance = this + this.reset() + } + } + + public configure(options?: { + isTracingEnabled?: boolean + debugVerbose?: boolean + agentIDE?: CodyIDE + extensionAgentVersion?: string + }): void { + // If the service is already initialized or if it is not the instance that is being used, return + if (this.isInitialized || WebviewOpenTelemetryService.instance !== this) { + return + } + + const { + isTracingEnabled = true, + debugVerbose = false, + agentIDE, + extensionAgentVersion, + } = options || {} + this.isTracingEnabled = isTracingEnabled + this.agentIDE = agentIDE + this.extensionAgentVersion = extensionAgentVersion + const logLevel = debugVerbose ? DiagLogLevel.INFO : DiagLogLevel.ERROR + diag.setLogger(new DiagConsoleLogger(), logLevel) + + try { + this.tracerProvider = new WebTracerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'cody-webview', + [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0', + }), + }) + if (this.isTracingEnabled) { + this.tracerProvider.addSpanProcessor( + new BatchSpanProcessor( + new CodyTraceExporterWeb({ + isTracingEnabled: true, + clientPlatform: this.agentIDE ?? ('defaultIDE' as CodyIDE), + agentVersion: this.extensionAgentVersion, + }) + ) + ) + } + + this.tracerProvider.register() + this.isInitialized = true + console.log('WebviewOpenTelemetryService initialized') + } catch (error) { + console.error('Failed to initialize OpenTelemetry:', error) + this.reset() + } + } + + public reset(): void { + if (this.tracerProvider) { + this.unloadInstrumentations?.() + this.tracerProvider.shutdown() + this.tracerProvider = undefined + this.isInitialized = false + } + } + + public dispose(): void { + if (WebviewOpenTelemetryService.instance !== this) { + return + } + this.reset() + WebviewOpenTelemetryService.instance = null + } + + public static getInstance(): WebviewOpenTelemetryService { + if (!WebviewOpenTelemetryService.instance) { + WebviewOpenTelemetryService.instance = new WebviewOpenTelemetryService() + } + return WebviewOpenTelemetryService.instance + } +} diff --git a/web/lib/components/CodyWebChat.tsx b/web/lib/components/CodyWebChat.tsx index ea93c84a784b..7faa27babcf5 100644 --- a/web/lib/components/CodyWebChat.tsx +++ b/web/lib/components/CodyWebChat.tsx @@ -32,6 +32,7 @@ import { useCodyWebAgent } from './use-cody-agent' // Include global Cody Web styles to the styles bundle import '../global-styles/styles.css' import type { DefaultContext } from '@sourcegraph/cody-shared/src/codebase-context/messages' +import { WebviewOpenTelemetryService } from 'cody-ai/webviews/utils/webviewOpenTelemetryService' import styles from './CodyWebChat.module.css' import { ChatSkeleton } from './skeleton/ChatSkeleton' @@ -228,6 +229,8 @@ const CodyWebPanel: FC = props => { return { initialContext, corpusContext } }, [initialContextData]) + const webviewTelemetryService = WebviewOpenTelemetryService.getInstance() + const wrappers = useMemo( () => getAppWrappers({ @@ -235,8 +238,9 @@ const CodyWebPanel: FC = props => { telemetryRecorder, config, staticDefaultContext, + webviewTelemetryService, }), - [vscodeAPI, telemetryRecorder, config, staticDefaultContext] + [vscodeAPI, telemetryRecorder, config, staticDefaultContext, webviewTelemetryService] ) const CONTEXT_MENTIONS_SETTINGS = useMemo(() => {