1+ import * as diagnosticsChannel from 'diagnostics_channel'
2+
13import * as api from '@opentelemetry/api'
24import { SugaredTracer } from '@opentelemetry/api/experimental'
35import { _globalThis } from '@opentelemetry/core'
46import { InstrumentationConfig , type Instrumentation } from '@opentelemetry/instrumentation'
57
68export interface FetchInstrumentationConfig extends InstrumentationConfig {
7- getRequestAttributes ?( headers : Request ) : api . Attributes
8- getResponseAttributes ?( response : Response ) : api . Attributes
9+ getRequestAttributes ?( request : FetchRequest ) : api . Attributes
10+ getResponseAttributes ?( response : FetchResponse ) : api . Attributes
911 skipURLs ?: ( string | RegExp ) [ ]
1012 skipHeaders ?: ( string | RegExp ) [ ] | true
1113 redactHeaders ?: ( string | RegExp ) [ ] | true
@@ -14,12 +16,15 @@ export interface FetchInstrumentationConfig extends InstrumentationConfig {
1416export class FetchInstrumentation implements Instrumentation {
1517 instrumentationName = '@netlify/otel/instrumentation-fetch'
1618 instrumentationVersion = '1.0.0'
17- private originalFetch : typeof fetch | null = null
1819 private config : FetchInstrumentationConfig
1920 private provider ?: api . TracerProvider
2021
22+ declare private _channelSubs : ListenerRecord [ ]
23+ private _recordFromReq = new WeakMap < FetchRequest , api . Span > ( )
24+
2125 constructor ( config : FetchInstrumentationConfig = { } ) {
2226 this . config = config
27+ this . _channelSubs = [ ]
2328 }
2429
2530 getConfig ( ) : FetchInstrumentationConfig {
@@ -36,9 +41,10 @@ export class FetchInstrumentation implements Instrumentation {
3641 return this . provider
3742 }
3843
39- private annotateFromRequest ( span : api . Span , request : Request ) : void {
44+ private annotateFromRequest ( span : api . Span , request : FetchRequest ) : void {
4045 const extras = this . config . getRequestAttributes ?.( request ) ?? { }
41- const url = new URL ( request . url )
46+ const url = new URL ( request . path , request . origin )
47+
4248 // these are based on @opentelemetry /semantic-convention 1.36
4349 span . setAttributes ( {
4450 ...extras ,
@@ -52,19 +58,25 @@ export class FetchInstrumentation implements Instrumentation {
5258 } )
5359 }
5460
55- private annotateFromResponse ( span : api . Span , response : Response ) : void {
61+ private annotateFromResponse ( span : api . Span , response : FetchResponse ) : void {
5662 const extras = this . config . getResponseAttributes ?.( response ) ?? { }
5763
5864 // these are based on @opentelemetry /semantic-convention 1.36
5965 span . setAttributes ( {
6066 ...extras ,
61- 'http.response.status_code' : response . status ,
67+ 'http.response.status_code' : response . statusCode ,
6268 ...this . prepareHeaders ( 'response' , response . headers ) ,
6369 } )
64- span . setStatus ( { code : response . status >= 400 ? api . SpanStatusCode . ERROR : api . SpanStatusCode . UNSET } )
70+
71+ span . setStatus ( {
72+ code : response . statusCode >= 400 ? api . SpanStatusCode . ERROR : api . SpanStatusCode . UNSET ,
73+ } )
6574 }
6675
67- private prepareHeaders ( type : 'request' | 'response' , headers : Headers ) : api . Attributes {
76+ private prepareHeaders (
77+ type : 'request' | 'response' ,
78+ headers : FetchRequest [ 'headers' ] | FetchResponse [ 'headers' ] ,
79+ ) : api . Attributes {
6880 if ( this . config . skipHeaders === true ) {
6981 return { }
7082 }
@@ -74,8 +86,9 @@ export class FetchInstrumentation implements Instrumentation {
7486 const everythingSkipped = skips . some ( ( skip ) => everything . includes ( skip . toString ( ) ) )
7587 const attributes : api . Attributes = { }
7688 if ( everythingSkipped ) return attributes
77- const entries = headers . entries ( )
78- for ( const [ key , value ] of entries ) {
89+ for ( let idx = 0 ; idx < headers . length ; idx = idx + 2 ) {
90+ const key = headers [ idx ] . toString ( ) . toLowerCase ( )
91+ const value = headers [ idx + 1 ] . toString ( )
7992 if ( skips . some ( ( skip ) => ( typeof skip == 'string' ? skip == key : skip . test ( key ) ) ) ) {
8093 continue
8194 }
@@ -92,6 +105,16 @@ export class FetchInstrumentation implements Instrumentation {
92105 return attributes
93106 }
94107
108+ private getRequestMethod ( original : string ) : string {
109+ const acceptedMethods = [ 'HEAD' , 'GET' , 'POST' , 'PUT' , 'PATCH' , 'DELETE' ]
110+
111+ if ( acceptedMethods . includes ( original . toUpperCase ( ) ) ) {
112+ return original . toUpperCase ( )
113+ }
114+
115+ return '_OTHER'
116+ }
117+
95118 private getTracer ( ) : SugaredTracer | undefined {
96119 if ( ! this . provider ) {
97120 return undefined
@@ -105,39 +128,110 @@ export class FetchInstrumentation implements Instrumentation {
105128 return new SugaredTracer ( tracer )
106129 }
107130
108- /**
109- * patch global fetch
110- */
111131 enable ( ) : void {
112- const originalFetch = _globalThis . fetch
113- this . originalFetch = originalFetch
114- _globalThis . fetch = async ( resource : RequestInfo | URL , options ?: RequestInit ) : Promise < Response > => {
115- const url = typeof resource === 'string' ? resource : resource instanceof URL ? resource . href : resource . url
116- const tracer = this . getTracer ( )
117- if (
118- ! tracer ||
119- this . config . skipURLs ?. some ( ( skip ) => ( typeof skip == 'string' ? url . startsWith ( skip ) : skip . test ( url ) ) )
120- ) {
121- return await originalFetch ( resource , options )
122- }
132+ // Avoid to duplicate subscriptions
133+ if ( this . _channelSubs . length > 0 ) return
134+
135+ // https://undici.nodejs.org/#/docs/api/DiagnosticsChannel?id=diagnostics-channel-support
136+ this . subscribe ( 'undici:request:create' , this . onRequestCreate . bind ( this ) )
137+ this . subscribe ( 'undici:request:headers' , this . onRequestHeaders . bind ( this ) )
138+ this . subscribe ( 'undici:request:trailers' , this . onRequestEnd . bind ( this ) )
139+ this . subscribe ( 'undici:request:error' , this . onRequestError . bind ( this ) )
140+ }
123141
124- return tracer . withActiveSpan ( 'fetch' , async ( span ) => {
125- const request = new Request ( resource , options )
126- this . annotateFromRequest ( span , request )
127- const response = await originalFetch ( request , options )
128- this . annotateFromResponse ( span , response )
129- return response
130- } )
131- }
142+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
143+ private subscribe ( channelName : string , onMessage : ( message : any , name : string | symbol ) => void ) {
144+ diagnosticsChannel . subscribe ( channelName , onMessage )
145+
146+ const unsubscribe = ( ) => diagnosticsChannel . unsubscribe ( channelName , onMessage )
147+ this . _channelSubs . push ( { name : channelName , unsubscribe } )
132148 }
133149
134- /**
135- * unpatch global fetch
136- */
137- disable ( ) : void {
138- if ( this . originalFetch ) {
139- _globalThis . fetch = this . originalFetch
140- this . originalFetch = null
150+ disable ( ) {
151+ this . _channelSubs . forEach ( ( sub ) => {
152+ sub . unsubscribe ( )
153+ } )
154+ this . _channelSubs . length = 0
155+ }
156+
157+ private onRequestCreate ( { request } : { request : FetchRequest } ) : void {
158+ const tracer = this . getTracer ( )
159+ const url = new URL ( request . path , request . origin )
160+
161+ if (
162+ ! tracer ||
163+ request . method === 'CONNECT' ||
164+ this . config . skipURLs ?. some ( ( skip ) => ( typeof skip == 'string' ? url . href . startsWith ( skip ) : skip . test ( url . href ) ) )
165+ ) {
166+ return
141167 }
168+
169+ const span = tracer . startSpan (
170+ this . getRequestMethod ( request . method ) ,
171+ {
172+ kind : api . SpanKind . CLIENT ,
173+ } ,
174+ api . context . active ( ) ,
175+ )
176+
177+ this . annotateFromRequest ( span , request )
178+
179+ this . _recordFromReq . set ( request , span )
180+ }
181+
182+ private onRequestHeaders ( { request, response } : { request : FetchRequest ; response : FetchResponse } ) : void {
183+ const span = this . _recordFromReq . get ( request )
184+ if ( ! span ) return
185+
186+ this . annotateFromResponse ( span , response )
187+ }
188+
189+ private onRequestError ( { request, error } : { request : FetchRequest ; error : Error } ) : void {
190+ const span = this . _recordFromReq . get ( request )
191+ if ( ! span ) return
192+
193+ span . recordException ( error )
194+ span . setStatus ( {
195+ code : api . SpanStatusCode . ERROR ,
196+ message : error . message ,
197+ } )
198+
199+ span . end ( )
200+ this . _recordFromReq . delete ( request )
142201 }
202+
203+ private onRequestEnd ( { request } : { request : FetchRequest ; response : FetchResponse } ) : void {
204+ const span = this . _recordFromReq . get ( request )
205+ if ( ! span ) return
206+
207+ span . end ( )
208+ this . _recordFromReq . delete ( request )
209+ }
210+ }
211+
212+ interface ListenerRecord {
213+ name : string
214+ unsubscribe : ( ) => void
215+ }
216+
217+ // https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/instrumentation-undici/src/types.ts
218+ interface FetchRequest {
219+ origin : string
220+ method : string
221+ path : string
222+ headers : string | ( string | string [ ] ) [ ]
223+ addHeader : ( name : string , value : string ) => void
224+ throwOnError : boolean
225+ completed : boolean
226+ aborted : boolean
227+ idempotent : boolean
228+ contentLength : number | null
229+ contentType : string | null
230+ body : unknown
231+ }
232+
233+ interface FetchResponse {
234+ headers : Buffer [ ]
235+ statusCode : number
236+ statusText : string
143237}
0 commit comments