├── .husky └── pre-commit ├── .trunk ├── .gitignore ├── configs │ ├── .shellcheckrc │ ├── .markdownlint.yaml │ └── .yamllint.yaml └── trunk.yaml ├── .eslintignore ├── .yarnrc.yml ├── .vscode └── launch.json ├── .npmignore ├── .gitignore ├── LICENSE ├── README.md ├── .github └── workflows │ ├── nodejs.yml │ └── codeql.yml ├── package.json ├── __tests__ └── index.spec.ts └── src └── index.ts /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint-staged -------------------------------------------------------------------------------- /.trunk/.gitignore: -------------------------------------------------------------------------------- 1 | *out 2 | *logs 3 | *actions 4 | *notifications 5 | *tools 6 | plugins 7 | user_trunk.yaml 8 | user.yaml 9 | tmp 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | coverage 3 | jest.config.js 4 | .eslintrc.js 5 | coconfig.?s 6 | src/generated 7 | node_modules 8 | migrations/ 9 | .yarn/ 10 | **/*.k6.js 11 | -------------------------------------------------------------------------------- /.trunk/configs/.shellcheckrc: -------------------------------------------------------------------------------- 1 | enable=all 2 | source-path=SCRIPTDIR 3 | disable=SC2154 4 | 5 | # If you're having issues with shellcheck following source, disable the errors via: 6 | # disable=SC1090 7 | # disable=SC1091 8 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.2.4.cjs 8 | -------------------------------------------------------------------------------- /.trunk/configs/.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # Autoformatter friendly markdownlint config (all formatting rules disabled) 2 | default: true 3 | blank_lines: false 4 | bullet: false 5 | html: false 6 | indentation: false 7 | line_length: false 8 | spaces: false 9 | url: false 10 | whitespace: false 11 | -------------------------------------------------------------------------------- /.trunk/configs/.yamllint.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | quoted-strings: 3 | required: only-when-needed 4 | extra-allowed: ['{|}'] 5 | empty-values: 6 | forbid-in-block-mappings: true 7 | forbid-in-flow-mappings: true 8 | key-duplicates: {} 9 | octal-values: 10 | forbid-implicit-octal: true 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Run tests", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}/node_modules/.bin/jest", 15 | "outFiles": [ 16 | "${workspaceFolder}/**/*.js" 17 | ] 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # npmignore should match gitignore, except for the /build directory 2 | # Custom 3 | .transpiled 4 | .nyc_output 5 | .yarnrc.yml 6 | .yarn 7 | 8 | src/**/*.spec.ts 9 | src/**/*.test.ts 10 | /__tests__ 11 | /__mocks__ 12 | __snapshots__ 13 | __image_snapshots__ 14 | 15 | # Stock 16 | *.seed 17 | *.log 18 | *.csv 19 | *.dat 20 | *.out 21 | *.pid 22 | *.gz 23 | *.orig 24 | 25 | work 26 | pids 27 | logs 28 | results 29 | coverage 30 | lib-cov 31 | html-report 32 | xunit.xml 33 | node_modules 34 | npm-debug.log 35 | 36 | .project 37 | .idea 38 | .settings 39 | .iml 40 | *.sublime-workspace 41 | *.sublime-project 42 | 43 | .DS_Store* 44 | ehthumbs.db 45 | Icon? 46 | Thumbs.db 47 | .AppleDouble 48 | .LSOverride 49 | .Spotlight-V100 50 | .Trashes -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | .transpiled 3 | .nyc_output 4 | 5 | # TypeScript incremental compilation cache 6 | *.tsbuildinfo 7 | 8 | # Stock 9 | *.seed 10 | *.log 11 | *.csv 12 | *.dat 13 | *.out 14 | *.pid 15 | *.gz 16 | *.orig 17 | 18 | work 19 | /build 20 | pids 21 | logs 22 | results 23 | coverage 24 | lib-cov 25 | html-report 26 | xunit.xml 27 | node_modules 28 | npm-debug.log 29 | 30 | .project 31 | .idea 32 | .settings 33 | .iml 34 | *.sublime-workspace 35 | *.sublime-project 36 | 37 | .DS_Store* 38 | ehthumbs.db 39 | Icon? 40 | Thumbs.db 41 | .AppleDouble 42 | .LSOverride 43 | .Spotlight-V100 44 | .Trashes 45 | 46 | .yarn/* 47 | !.yarn/patches 48 | !.yarn/plugins 49 | !.yarn/releases 50 | !.yarn/sdks 51 | !.yarn/versions 52 | 53 | .node_repl_history 54 | 55 | # TypeScript incremental compilation cache 56 | *.tsbuildinfo 57 | # Added by coconfig 58 | .eslintignore 59 | .npmignore 60 | tsconfig.json 61 | tsconfig.build.json 62 | .prettierrc.js 63 | .eslintrc.js 64 | .commitlintrc.json 65 | vitest.config.ts 66 | -------------------------------------------------------------------------------- /.trunk/trunk.yaml: -------------------------------------------------------------------------------- 1 | # This file controls the behavior of Trunk: https://docs.trunk.io/cli 2 | # To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml 3 | version: 0.1 4 | cli: 5 | version: 1.21.0 6 | plugins: 7 | sources: 8 | - id: trunk 9 | ref: v1.4.5 10 | uri: https://github.com/trunk-io/plugins 11 | runtimes: 12 | enabled: 13 | - go@1.21.0 14 | - node@18.12.1 15 | - python@3.10.8 16 | lint: 17 | enabled: 18 | - actionlint@1.6.27 19 | - checkov@3.2.53 20 | - eslint@8.57.0 21 | - git-diff-check 22 | - markdownlint@0.39.0 23 | - osv-scanner@1.7.0 24 | - prettier@3.2.5 25 | - shellcheck@0.10.0 26 | - shfmt@3.6.0 27 | - trivy@0.50.1 28 | - trufflehog@3.71.0 29 | - yamllint@1.35.1 30 | ignore: 31 | - linters: [ALL] 32 | paths: 33 | - .yarn/** 34 | - yarn.lock 35 | actions: 36 | enabled: 37 | - commitlint 38 | - trunk-announce 39 | - trunk-check-pre-push 40 | - trunk-fmt-pre-commit 41 | - trunk-upgrade-available 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 GasBuddy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # opentelemetry-instrumentation-fetch-node 2 | 3 | [![TypeScript](https://badges.frapsoft.com/typescript/code/typescript.png?v=101)](https://github.com/ellerbrock/typescript-badges/) 4 | 5 | ![main CI](https://github.com/gas-buddy/opentelemetry-instrumentation-fetch-node/actions/workflows/nodejs.yml/badge.svg) 6 | 7 | [![npm version](https://badge.fury.io/js/@gasbuddy%2Fopentelemetry-instrumentation-fetch-node.svg)](https://badge.fury.io/js/opentelemetry-instrumentation-fetch-node) 8 | 9 | OpenTelemetry Node 18+ native fetch automatic instrumentation package. 10 | 11 | Existing instrumentation packages (like [@opentelemetry/instrumentation-http](https://www.npmjs.com/package/@opentelemetry/instrumentation-http)) do not work with Node 18+ native fetch, which is based on the [undici module](https://undici.nodejs.org/#/) but packaged in a "strange" way (some sort of internal bundle that I don't really understand). This module uses the new Node diagnostics channel to instrument native fetch. 12 | 13 | Note that due to the fact that fetch is lazy loaded in Node, this module will kick off a "phony" fetch 14 | to an unparseable URL (blank string) to get the library to load so we don't miss any events (because the 15 | diagnostics channel would not yet exist). 16 | 17 | See the tests for an example setup - note the onRequest event that allows you to add outbound headers or 18 | span attributes or what have you. 19 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | pull_request: 5 | types: [assigned, opened, synchronize, reopened] 6 | push: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | prepare: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Cleanup stale actions 18 | uses: styfle/cancel-workflow-action@0.11.0 19 | with: 20 | access_token: ${{ github.token }} 21 | 22 | build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Use Node.js 18 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: 20 30 | - name: npm install, lint, build, and test 31 | run: | 32 | yarn install --immutable 33 | yarn lint 34 | yarn build 35 | yarn test 36 | env: 37 | CI: true 38 | 39 | publish-npm: 40 | needs: build 41 | permissions: 42 | contents: write 43 | issues: write 44 | id-token: write 45 | pull-requests: write 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v1 49 | - uses: actions/setup-node@v3 50 | with: 51 | node-version: 20 52 | registry-url: https://registry.npmjs.org/ 53 | - run: yarn install --immutable 54 | - run: yarn build 55 | 56 | - name: Release 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | NODE_AUTH_TOKEN: ${{ secrets.npm_token }} 60 | run: | 61 | yarn dlx semantic-release 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentelemetry-instrumentation-fetch-node", 3 | "version": "0.0.0", 4 | "description": "OpenTelemetry Node 18+ native fetch automatic instrumentation package", 5 | "main": "build/index.js", 6 | "types": "build/index.d.ts", 7 | "scripts": { 8 | "test": "vitest", 9 | "build": "tsc -p tsconfig.build.json", 10 | "clean": "yarn dlx rimraf ./build", 11 | "postinstall": "yarn coconfig", 12 | "lint": "eslint src" 13 | }, 14 | "engines": { 15 | "node": ">18.0.0" 16 | }, 17 | "author": "GasBuddy Developers ", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/gas-buddy/opentelemetry-instrumentation-fetch-node.git" 22 | }, 23 | "keywords": [ 24 | "typescript", 25 | "gasbuddy", 26 | "opentelemetry", 27 | "fetch", 28 | "undici" 29 | ], 30 | "config": { 31 | "coconfig": "@openapi-typescript-infra/coconfig" 32 | }, 33 | "release": { 34 | "branches": [ 35 | "main" 36 | ], 37 | "plugins": [ 38 | "@semantic-release/commit-analyzer", 39 | "@semantic-release/release-notes-generator", 40 | [ 41 | "@semantic-release/exec", 42 | { 43 | "publishCmd": "yarn dlx pinst --disable" 44 | } 45 | ], 46 | "@semantic-release/npm", 47 | "@semantic-release/github" 48 | ] 49 | }, 50 | "devDependencies": { 51 | "@commitlint/config-conventional": "^19.2.2", 52 | "@openapi-typescript-infra/coconfig": "^4.4.0", 53 | "@opentelemetry/api": "^1.6.0", 54 | "@opentelemetry/sdk-trace-base": "^1.17.0", 55 | "@opentelemetry/sdk-trace-node": "^1.17.0", 56 | "@semantic-release/commit-analyzer": "^13.0.0", 57 | "@semantic-release/exec": "^6.0.3", 58 | "@semantic-release/github": "^10.1.0", 59 | "@semantic-release/release-notes-generator": "^14.0.1", 60 | "@typescript-eslint/eslint-plugin": "^6.21.0", 61 | "@typescript-eslint/parser": "^6.21.0", 62 | "coconfig": "^1.5.2", 63 | "eslint": "^8.57.0", 64 | "eslint-config-prettier": "^9.0.0", 65 | "eslint-plugin-import": "^2.29.0", 66 | "semantic-release": "^24.0.0", 67 | "typescript": "^5.5.3", 68 | "vitest": "^2.0.1" 69 | }, 70 | "peerDependencies": { 71 | "@opentelemetry/api": "^1.6.0" 72 | }, 73 | "dependencies": { 74 | "@opentelemetry/instrumentation": "^0.46.0", 75 | "@opentelemetry/semantic-conventions": "^1.17.0" 76 | }, 77 | "packageManager": "yarn@3.2.4" 78 | } 79 | -------------------------------------------------------------------------------- /__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | 3 | import { expect, test, vi } from 'vitest'; 4 | import { ReadableSpan, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; 5 | import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; 6 | import { registerInstrumentations } from '@opentelemetry/instrumentation'; 7 | import { SpanStatusCode } from '@opentelemetry/api'; 8 | import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; 9 | 10 | import { FetchInstrumentation } from '../src'; 11 | 12 | test('Basic function', async () => { 13 | const provider = new NodeTracerProvider({}); 14 | const exportedSpans: ReadableSpan[] = []; 15 | 16 | provider.addSpanProcessor( 17 | new SimpleSpanProcessor({ 18 | export(spans, resultCallback) { 19 | exportedSpans.push(...spans); 20 | resultCallback({ code: 0 }); 21 | }, 22 | shutdown: vi.fn(), 23 | }), 24 | ); 25 | provider.register(); 26 | 27 | const config = { 28 | onRequest: vi.fn(), 29 | }; 30 | 31 | registerInstrumentations({ 32 | instrumentations: [new FetchInstrumentation(config)], 33 | tracerProvider: provider, 34 | }); 35 | 36 | const server = http.createServer((req, res) => { 37 | expect(req.headers.traceparent).toBeTruthy(); 38 | if (req.headers['x-error']) { 39 | res.writeHead(500); 40 | } else { 41 | res.setHeader('Content-Length', '2'); 42 | res.writeHead(200); 43 | } 44 | res.end('OK'); 45 | res.destroy(); 46 | }); 47 | await new Promise((accept) => { 48 | server.listen(12345, accept); 49 | }); 50 | 51 | await fetch('http://localhost:12345/my-path', { keepalive: false }); 52 | expect(config.onRequest).toHaveBeenCalledTimes(1); 53 | await fetch('http://localhost:12345', { 54 | headers: { 'x-error': '1' }, 55 | keepalive: false, 56 | }); 57 | expect(config.onRequest).toHaveBeenCalledTimes(2); 58 | 59 | await new Promise((accept, reject) => { 60 | server.close((error) => { 61 | if (error) { 62 | reject(error); 63 | } else { 64 | accept(); 65 | } 66 | }); 67 | }); 68 | 69 | try { 70 | await fetch('http://localhost:12345'); 71 | throw new Error('Expected fetch exception'); 72 | } catch (e) { 73 | expect(config.onRequest).toHaveBeenCalledTimes(3); 74 | } 75 | 76 | expect(exportedSpans.length).toBe(3); 77 | expect(exportedSpans[0].status.code).toEqual(SpanStatusCode.OK); 78 | expect(exportedSpans[0].attributes[SemanticAttributes.HTTP_URL]).toEqual( 79 | 'http://localhost:12345/my-path', 80 | ); 81 | expect(exportedSpans[1].status.code).toEqual(SpanStatusCode.ERROR); 82 | expect(exportedSpans[1].status.message).toMatch(/500/); 83 | expect(exportedSpans[1].attributes[SemanticAttributes.HTTP_URL]).toEqual( 84 | 'http://localhost:12345/', 85 | ); 86 | expect(exportedSpans[2].status.code).toEqual(SpanStatusCode.ERROR); 87 | expect(exportedSpans[2].status.message).toMatch(/ECONNREFUSED/); 88 | }); 89 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '35 2 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Portions from https://github.com/elastic/apm-agent-nodejs 3 | * Copyright Elasticsearch B.V. and other contributors where applicable. 4 | * Licensed under the BSD 2-Clause License; you may not use this file except in 5 | * compliance with the BSD 2-Clause License. 6 | * 7 | */ 8 | import diagch from 'node:diagnostics_channel'; 9 | 10 | import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; 11 | import { Instrumentation, InstrumentationConfig } from '@opentelemetry/instrumentation'; 12 | import { 13 | Attributes, 14 | context, 15 | Meter, 16 | MeterProvider, 17 | metrics, 18 | propagation, 19 | Span, 20 | SpanKind, 21 | SpanStatusCode, 22 | trace, 23 | Tracer, 24 | TracerProvider, 25 | } from '@opentelemetry/api'; 26 | 27 | interface ListenerRecord { 28 | name: string; 29 | channel: diagch.Channel; 30 | onMessage: diagch.ChannelListener; 31 | } 32 | 33 | interface FetchRequest { 34 | method: string; 35 | origin: string; 36 | path: string; 37 | headers: string | string[]; 38 | } 39 | 40 | interface FetchResponse { 41 | headers: Buffer[]; 42 | statusCode: number; 43 | } 44 | 45 | export interface FetchInstrumentationConfig extends InstrumentationConfig { 46 | ignoreRequestHook?: (request: FetchRequest) => boolean; 47 | onRequest?: (args: { 48 | request: FetchRequest; 49 | span: Span; 50 | additionalHeaders: Record; 51 | }) => void; 52 | } 53 | 54 | function getMessage(error: Error) { 55 | if (error instanceof AggregateError) { 56 | return error.errors.map((e) => e.message).join(', '); 57 | } 58 | return error.message; 59 | } 60 | 61 | // Get the content-length from undici response headers. 62 | // `headers` is an Array of buffers: [k, v, k, v, ...]. 63 | // If the header is not present, or has an invalid value, this returns null. 64 | function contentLengthFromResponseHeaders(headers: Buffer[]) { 65 | const name = 'content-length'; 66 | for (let i = 0; i < headers.length; i += 2) { 67 | const k = headers[i]; 68 | if (k.length === name.length && k.toString().toLowerCase() === name) { 69 | const v = Number(headers[i + 1]); 70 | if (!Number.isNaN(Number(v))) { 71 | return v; 72 | } 73 | return undefined; 74 | } 75 | } 76 | return undefined; 77 | } 78 | 79 | async function loadFetch() { 80 | try { 81 | await fetch('data:text'); 82 | } catch (_) { 83 | // 84 | } 85 | } 86 | 87 | // A combination of https://github.com/elastic/apm-agent-nodejs and 88 | // https://github.com/gadget-inc/opentelemetry-instrumentations/blob/main/packages/opentelemetry-instrumentation-undici/src/index.ts 89 | export class FetchInstrumentation implements Instrumentation { 90 | // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for 91 | // unsubscribing. 92 | private channelSubs: Array | undefined; 93 | 94 | private spanFromReq = new WeakMap(); 95 | 96 | private tracer: Tracer; 97 | 98 | private config: FetchInstrumentationConfig; 99 | 100 | private meter: Meter; 101 | 102 | public readonly instrumentationName = 'opentelemetry-instrumentation-node-18-fetch'; 103 | 104 | public readonly instrumentationVersion = '1.0.0'; 105 | 106 | public readonly instrumentationDescription = 107 | 'Instrumentation for Node 18 fetch via diagnostics_channel'; 108 | 109 | private subscribeToChannel(diagnosticChannel: string, onMessage: diagch.ChannelListener) { 110 | const channel = diagch.channel(diagnosticChannel); 111 | channel.subscribe(onMessage); 112 | this.channelSubs!.push({ 113 | name: diagnosticChannel, 114 | channel, 115 | onMessage, 116 | }); 117 | } 118 | 119 | constructor(config: FetchInstrumentationConfig) { 120 | // Force load fetch API (since it's lazy loaded in Node 18) 121 | loadFetch(); 122 | this.channelSubs = []; 123 | this.meter = metrics.getMeter(this.instrumentationName, this.instrumentationVersion); 124 | this.tracer = trace.getTracer(this.instrumentationName, this.instrumentationVersion); 125 | this.config = { ...config }; 126 | } 127 | 128 | disable(): void { 129 | this.channelSubs?.forEach((sub) => sub.channel.unsubscribe(sub.onMessage)); 130 | } 131 | 132 | enable(): void { 133 | this.subscribeToChannel('undici:request:create', (args) => 134 | this.onRequest(args as { request: FetchRequest }), 135 | ); 136 | this.subscribeToChannel('undici:request:headers', (args) => 137 | this.onHeaders(args as { request: FetchRequest; response: FetchResponse }), 138 | ); 139 | this.subscribeToChannel('undici:request:trailers', (args) => 140 | this.onDone(args as { request: FetchRequest }), 141 | ); 142 | this.subscribeToChannel('undici:request:error', (args) => 143 | this.onError(args as { request: FetchRequest; error: Error }), 144 | ); 145 | } 146 | 147 | setTracerProvider(tracerProvider: TracerProvider): void { 148 | this.tracer = tracerProvider.getTracer(this.instrumentationName, this.instrumentationVersion); 149 | } 150 | 151 | public setMeterProvider(meterProvider: MeterProvider): void { 152 | this.meter = meterProvider.getMeter(this.instrumentationName, this.instrumentationVersion); 153 | } 154 | 155 | setConfig(config: InstrumentationConfig): void { 156 | this.config = { ...config }; 157 | } 158 | 159 | getConfig(): InstrumentationConfig { 160 | return this.config; 161 | } 162 | 163 | onRequest({ request }: { request: FetchRequest }): void { 164 | // Don't instrument CONNECT - see comments at: 165 | // https://github.com/elastic/apm-agent-nodejs/blob/c55b1d8c32b2574362fc24d81b8e173ce2f75257/lib/instrumentation/modules/undici.js#L24 166 | if (request.method === 'CONNECT') { 167 | return; 168 | } 169 | if (this.config.ignoreRequestHook && this.config.ignoreRequestHook(request) === true) { 170 | return; 171 | } 172 | 173 | const span = this.tracer.startSpan(`HTTP ${request.method}`, { 174 | kind: SpanKind.CLIENT, 175 | attributes: { 176 | [SemanticAttributes.HTTP_URL]: getAbsoluteUrl(request.origin, request.path), 177 | [SemanticAttributes.HTTP_METHOD]: request.method, 178 | [SemanticAttributes.HTTP_TARGET]: request.path, 179 | 'http.client': 'fetch', 180 | }, 181 | }); 182 | const requestContext = trace.setSpan(context.active(), span); 183 | const addedHeaders: Record = {}; 184 | propagation.inject(requestContext, addedHeaders); 185 | 186 | if (this.config.onRequest) { 187 | this.config.onRequest({ request, span, additionalHeaders: addedHeaders }); 188 | } 189 | 190 | if (Array.isArray(request.headers)) { 191 | request.headers.push(...Object.entries(addedHeaders).flat()); 192 | } else { 193 | request.headers += Object.entries(addedHeaders) 194 | .map(([k, v]) => `${k}: ${v}\r\n`) 195 | .join(''); 196 | } 197 | this.spanFromReq.set(request, span); 198 | } 199 | 200 | onHeaders({ request, response }: { request: FetchRequest; response: FetchResponse }): void { 201 | const span = this.spanFromReq.get(request); 202 | 203 | if (span !== undefined) { 204 | // We are currently *not* capturing response headers, even though the 205 | // intake API does allow it, because none of the other `setHttpContext` 206 | // uses currently do. 207 | 208 | const cLen = contentLengthFromResponseHeaders(response.headers); 209 | const attrs: Attributes = { 210 | [SemanticAttributes.HTTP_STATUS_CODE]: response.statusCode, 211 | }; 212 | if (cLen) { 213 | attrs[SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH] = cLen; 214 | } 215 | span.setAttributes(attrs); 216 | span.setStatus({ 217 | code: response.statusCode >= 400 ? SpanStatusCode.ERROR : SpanStatusCode.OK, 218 | message: String(response.statusCode), 219 | }); 220 | } 221 | } 222 | 223 | onDone({ request }: { request: FetchRequest }): void { 224 | const span = this.spanFromReq.get(request); 225 | if (span !== undefined) { 226 | span.end(); 227 | this.spanFromReq.delete(request); 228 | } 229 | } 230 | 231 | onError({ request, error }: { request: FetchRequest; error: Error }): void { 232 | const span = this.spanFromReq.get(request); 233 | if (span !== undefined) { 234 | span.recordException(error); 235 | span.setStatus({ 236 | code: SpanStatusCode.ERROR, 237 | message: getMessage(error), 238 | }); 239 | span.end(); 240 | } 241 | } 242 | } 243 | 244 | function getAbsoluteUrl(origin: string, path: string = '/'): string { 245 | const url = `${origin}`; 246 | 247 | if (origin.endsWith('/') && path.startsWith('/')) { 248 | return `${url}${path.slice(1)}`; 249 | } 250 | 251 | if (!origin.endsWith('/') && !path.startsWith('/')) { 252 | return `${url}/${path.slice(1)}`; 253 | } 254 | 255 | return `${url}${path}`; 256 | } 257 | --------------------------------------------------------------------------------