diff --git a/docs/core/tracer.md b/docs/core/tracer.md index c6af66a67a..f3f4f5cc00 100644 --- a/docs/core/tracer.md +++ b/docs/core/tracer.md @@ -8,7 +8,7 @@ Tracer is an opinionated thin wrapper for [AWS X-Ray SDK for Node.js](https://gi ## Key features * Auto-capturing cold start and service name as annotations, and responses or full exceptions as metadata. -* Automatically tracing HTTP(S) clients and generating segments for each request. +* Automatically tracing HTTP(S) clients including `fetch` and generating segments for each request. * Supporting tracing functions via decorators, middleware, and manual instrumentation. * Supporting tracing AWS SDK v2 and v3 via AWS X-Ray SDK for Node.js. * Auto-disable tracing when not running in the Lambda environment. @@ -211,12 +211,12 @@ If you're looking to shave a few microseconds, or milliseconds depending on your ### Tracing HTTP requests -When your function makes calls to HTTP APIs, Tracer automatically traces those calls and add the API to the service graph as a downstream service. +When your function makes outgoing requests to APIs, Tracer automatically traces those calls and adds the API to the service graph as a downstream service. You can opt-out from this feature by setting the **`POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS=false`** environment variable or by passing the `captureHTTPSRequests: false` option to the `Tracer` constructor. !!! info - The following snippet shows how to trace [axios](https://www.npmjs.com/package/axios) requests, but you can use any HTTP client library built on top of [http](https://nodejs.org/api/http.html) or [https](https://nodejs.org/api/https.html). + The following snippet shows how to trace [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) requests, but you can use any HTTP client library built on top it, or on [http](https://nodejs.org/api/http.html), and [https](https://nodejs.org/api/https.html). Support to 3rd party HTTP clients is provided on a best effort basis. === "index.ts" @@ -225,9 +225,6 @@ You can opt-out from this feature by setting the **`POWERTOOLS_TRACER_CAPTURE_HT --8<-- "docs/snippets/tracer/captureHTTP.ts" ``` - 1. You can install the [axios](https://www.npmjs.com/package/axios) package using `npm i axios` -=== "Example Raw X-Ray Trace excerpt" - ```json hl_lines="6 9 12-21" { "id": "22883fbc730e3a0b", diff --git a/docs/snippets/tracer/captureHTTP.ts b/docs/snippets/tracer/captureHTTP.ts index 506fbb0778..5c12693839 100644 --- a/docs/snippets/tracer/captureHTTP.ts +++ b/docs/snippets/tracer/captureHTTP.ts @@ -1,5 +1,4 @@ import { Tracer } from '@aws-lambda-powertools/tracer'; -import axios from 'axios'; // (1) new Tracer({ serviceName: 'serverlessAirline' }); @@ -7,5 +6,5 @@ export const handler = async ( _event: unknown, _context: unknown ): Promise => { - await axios.get('https://httpbin.org/status/200'); + await fetch('https://httpbin.org/status/200'); }; diff --git a/packages/tracer/src/Tracer.ts b/packages/tracer/src/Tracer.ts index 4b6f40f59a..7be6674705 100644 --- a/packages/tracer/src/Tracer.ts +++ b/packages/tracer/src/Tracer.ts @@ -16,7 +16,7 @@ import type { CaptureMethodOptions, } from './types/Tracer.js'; import { ProviderService } from './provider/ProviderService.js'; -import type { ProviderServiceInterface } from './types/ProviderServiceInterface.js'; +import type { ProviderServiceInterface } from './types/ProviderService.js'; import type { Segment, Subsegment } from 'aws-xray-sdk-core'; import xraySdk from 'aws-xray-sdk-core'; const { Subsegment: XraySubsegment } = xraySdk; @@ -153,6 +153,7 @@ class Tracer extends Utility implements TracerInterface { this.provider = new ProviderService(); if (this.isTracingEnabled() && this.captureHTTPsRequests) { this.provider.captureHTTPsGlobal(); + this.provider.instrumentFetch(); } if (!this.isTracingEnabled()) { // Tell x-ray-sdk to not throw an error if context is missing but tracing is disabled diff --git a/packages/tracer/src/provider/ProviderService.ts b/packages/tracer/src/provider/ProviderService.ts index 2194a8b8d9..dd5de88c26 100644 --- a/packages/tracer/src/provider/ProviderService.ts +++ b/packages/tracer/src/provider/ProviderService.ts @@ -1,8 +1,9 @@ -import { Namespace } from 'cls-hooked'; +import type { Namespace } from 'cls-hooked'; import type { ProviderServiceInterface, ContextMissingStrategy, -} from '../types/ProviderServiceInterface.js'; + HttpSubsegment, +} from '../types/ProviderService.js'; import type { Segment, Subsegment, Logger } from 'aws-xray-sdk-core'; import xraySdk from 'aws-xray-sdk-core'; const { @@ -21,6 +22,13 @@ const { setLogger, } = xraySdk; import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; +import { subscribe } from 'node:diagnostics_channel'; +import { + findHeaderAndDecode, + getOriginURL, + isHttpSubsegment, +} from './utilities.js'; +import type { DiagnosticsChannel } from 'undici-types'; class ProviderService implements ProviderServiceInterface { public captureAWS(awssdk: T): T { @@ -70,6 +78,119 @@ class ProviderService implements ProviderServiceInterface { return getSegment(); } + /** + * Instrument `fetch` requests with AWS X-Ray + * + * The instrumentation is done by subscribing to the `undici` events. When a request is created, + * a new subsegment is created with the hostname of the request. + * + * Then, when the headers are received, the subsegment is updated with the request and response details. + * + * Finally, when the request is completed, the subsegment is closed. + * + * @see {@link https://nodejs.org/api/diagnostics_channel.html#diagnostics_channel_channel_publish | Diagnostics Channel - Node.js Documentation} + */ + public instrumentFetch(): void { + /** + * Create a segment at the start of a request made with `undici` or `fetch`. + * + * @note that `message` must be `unknown` because that's the type expected by `subscribe` + * + * @param message The message received from the `undici` channel + */ + const onRequestStart = (message: unknown): void => { + const { request } = message as DiagnosticsChannel.RequestCreateMessage; + + const parentSubsegment = this.getSegment(); + if (parentSubsegment && request.origin) { + const origin = getOriginURL(request.origin); + const method = request.method; + + const subsegment = parentSubsegment.addNewSubsegment(origin.hostname); + subsegment.addAttribute('namespace', 'remote'); + + (subsegment as HttpSubsegment).http = { + request: { + url: origin.hostname, + method, + }, + }; + + this.setSegment(subsegment); + } + }; + + /** + * Enrich the subsegment with the response details, and close it. + * Then, set the parent segment as the active segment. + * + * @note that `message` must be `unknown` because that's the type expected by `subscribe` + * + * @param message The message received from the `undici` channel + */ + const onResponse = (message: unknown): void => { + const { response } = message as DiagnosticsChannel.RequestHeadersMessage; + + const subsegment = this.getSegment(); + if (isHttpSubsegment(subsegment)) { + const status = response.statusCode; + const contentLenght = findHeaderAndDecode( + response.headers, + 'content-length' + ); + + subsegment.http = { + ...subsegment.http, + response: { + status, + ...(contentLenght && { + content_length: parseInt(contentLenght), + }), + }, + }; + + if (status === 429) { + subsegment.addThrottleFlag(); + } + if (status >= 400 && status < 500) { + subsegment.addErrorFlag(); + } else if (status >= 500 && status < 600) { + subsegment.addFaultFlag(); + } + + subsegment.close(); + this.setSegment(subsegment.parent); + } + }; + + /** + * Add an error to the subsegment when the request fails. + * + * This is used to handle the case when the request fails to establish a connection with the server or timeouts. + * In all other cases, for example, when the server returns a 4xx or 5xx status code, the error is added in the `onResponse` function. + * + * @note that `message` must be `unknown` because that's the type expected by `subscribe` + * + * @param message The message received from the `undici` channel + */ + const onError = (message: unknown): void => { + const { error } = message as DiagnosticsChannel.RequestErrorMessage; + + const subsegment = this.getSegment(); + if (isHttpSubsegment(subsegment)) { + subsegment.addErrorFlag(); + error instanceof Error && subsegment.addError(error, true); + + subsegment.close(); + this.setSegment(subsegment.parent); + } + }; + + subscribe('undici:request:create', onRequestStart); + subscribe('undici:request:headers', onResponse); + subscribe('undici:request:error', onError); + } + public putAnnotation(key: string, value: string | number | boolean): void { const segment = this.getSegment(); if (segment === undefined) { diff --git a/packages/tracer/src/provider/utilities.ts b/packages/tracer/src/provider/utilities.ts new file mode 100644 index 0000000000..9e1780eb48 --- /dev/null +++ b/packages/tracer/src/provider/utilities.ts @@ -0,0 +1,63 @@ +import type { HttpSubsegment } from '../types/ProviderService.js'; +import type { Segment, Subsegment } from 'aws-xray-sdk-core'; +import { URL } from 'node:url'; + +const decoder = new TextDecoder(); + +/** + * The `fetch` implementation based on `undici` includes the headers as an array of encoded key-value pairs. + * This function finds the header with the given key and decodes the value. + * + * The function walks through the array of encoded headers and decodes the key of each pair. + * If the key matches the given key, the function returns the decoded value of the next element in the array. + * + * @param encodedHeaders The array of encoded headers + * @param key The key to search for + */ +const findHeaderAndDecode = ( + encodedHeaders: Uint8Array[], + key: string +): string | null => { + let foundIndex = -1; + for (let i = 0; i < encodedHeaders.length; i += 2) { + const header = decoder.decode(encodedHeaders[i]); + if (header.toLowerCase() === key) { + foundIndex = i; + break; + } + } + + if (foundIndex === -1) { + return null; + } + + return decoder.decode(encodedHeaders[foundIndex + 1]); +}; + +/** + * Type guard to check if the given subsegment is an `HttpSubsegment` + * + * @param subsegment The subsegment to check + */ +const isHttpSubsegment = ( + subsegment: Segment | Subsegment | undefined +): subsegment is HttpSubsegment => { + return ( + subsegment !== undefined && + 'http' in subsegment && + 'parent' in subsegment && + 'namespace' in subsegment && + subsegment.namespace === 'remote' + ); +}; + +/** + * Convert the origin url to a URL object when it is a string + * + * @param origin The origin url + */ +const getOriginURL = (origin: string | URL): URL => { + return origin instanceof URL ? origin : new URL(origin); +}; + +export { findHeaderAndDecode, isHttpSubsegment, getOriginURL }; diff --git a/packages/tracer/src/types/ProviderServiceInterface.ts b/packages/tracer/src/types/ProviderService.ts similarity index 69% rename from packages/tracer/src/types/ProviderServiceInterface.ts rename to packages/tracer/src/types/ProviderService.ts index 5a704fc705..edd6fa909a 100644 --- a/packages/tracer/src/types/ProviderServiceInterface.ts +++ b/packages/tracer/src/types/ProviderService.ts @@ -40,9 +40,35 @@ interface ProviderServiceInterface { captureHTTPsGlobal(): void; + /** + * Instrument `fetch` requests with AWS X-Ray + */ + instrumentFetch(): void; + putAnnotation(key: string, value: string | number | boolean): void; putMetadata(key: string, value: unknown, namespace?: string): void; } -export type { ProviderServiceInterface, ContextMissingStrategy }; +/** + * Subsegment that contains information for a request made to a remote service + */ +interface HttpSubsegment extends Subsegment { + namespace: 'remote'; + http: { + request?: { + url: string; + method?: string; + }; + response?: { + status: number; + content_length?: number; + }; + }; +} + +export type { + ProviderServiceInterface, + ContextMissingStrategy, + HttpSubsegment, +}; diff --git a/packages/tracer/src/types/Tracer.ts b/packages/tracer/src/types/Tracer.ts index ac2e399707..c7b4100431 100644 --- a/packages/tracer/src/types/Tracer.ts +++ b/packages/tracer/src/types/Tracer.ts @@ -22,6 +22,9 @@ import type { Segment, Subsegment } from 'aws-xray-sdk-core'; type TracerOptions = { enabled?: boolean; serviceName?: string; + /** + * Whether to trace outgoing HTTP requests made with the `http`, `https`, or `fetch` modules + */ captureHTTPsRequests?: boolean; customConfigService?: ConfigServiceInterface; }; diff --git a/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts b/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts index 6d60a7b53e..05668a7b9f 100644 --- a/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts +++ b/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts @@ -55,10 +55,13 @@ export class MyFunctionBase { Item: { id: `${serviceName}-${event.invocation}-sdkv3` }, }) ); - await axios.get( - 'https://docs.powertools.aws.dev/lambda/typescript/latest/', - { timeout: 5000 } - ); + const url = 'https://docs.powertools.aws.dev/lambda/typescript/latest/'; + // Add conditional behavior because fetch is not available in Node.js 16 - this can be removed once we drop support for Node.js 16 + if (process.version.startsWith('v16')) { + await axios.get(url, { timeout: 5000 }); + } else { + await fetch(url); + } const res = this.myMethod(); if (event.throw) { diff --git a/packages/tracer/tests/helpers/mockRequests.ts b/packages/tracer/tests/helpers/mockRequests.ts new file mode 100644 index 0000000000..8c5865e8d3 --- /dev/null +++ b/packages/tracer/tests/helpers/mockRequests.ts @@ -0,0 +1,72 @@ +import { channel } from 'node:diagnostics_channel'; +import type { URL } from 'node:url'; + +type MockFetchOptions = { + origin: string | URL; + method?: string; + headers?: { [key: string]: string }; +} & ( + | { + statusCode?: never; + throwError?: boolean; + } + | { + statusCode: number; + throwError?: never; + } +); + +/** + * Simulates a fetch request by publishing messages to the undici channel + * + * @see {@link https://nodejs.org/api/diagnostics_channel.html#diagnostics_channel_channel_publish | Diagnostics Channel - Node.js Documentation} + * + * @param options The options for the mock fetch + */ +const mockFetch = ({ + origin, + method, + statusCode, + headers, + throwError, +}: MockFetchOptions): void => { + const requestCreateChannel = channel('undici:request:create'); + const responseHeadersChannel = channel('undici:request:headers'); + const errorChannel = channel('undici:request:error'); + + const request = { + origin, + method: method ?? 'GET', + }; + + requestCreateChannel.publish({ + request, + }); + + if (throwError) { + const error = new AggregateError('Mock fetch error'); + + errorChannel.publish({ + request, + error, + }); + + throw error; + } + + const encoder = new TextEncoder(); + const encodedHeaders = []; + for (const [key, value] of Object.entries(headers ?? {})) { + encodedHeaders.push(encoder.encode(key)); + encodedHeaders.push(encoder.encode(value)); + } + responseHeadersChannel.publish({ + request, + response: { + statusCode: statusCode ?? 200, + headers: encodedHeaders, + }, + }); +}; + +export { mockFetch }; diff --git a/packages/tracer/tests/unit/ProviderService.test.ts b/packages/tracer/tests/unit/ProviderService.test.ts index f7fe81ab62..61a0a7c383 100644 --- a/packages/tracer/tests/unit/ProviderService.test.ts +++ b/packages/tracer/tests/unit/ProviderService.test.ts @@ -3,7 +3,8 @@ * * @group unit/tracer/providerservice */ -import { ProviderService } from '../../src/provider/ProviderService.js'; +import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { captureAsyncFunc, captureAWS, @@ -20,10 +21,13 @@ import { setSegment, Subsegment, } from 'aws-xray-sdk-core'; +import { channel } from 'node:diagnostics_channel'; import http from 'node:http'; import https from 'node:https'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; +import { ProviderService } from '../../src/provider/ProviderService.js'; +import type { HttpSubsegment } from '../../src/types/ProviderService.js'; +import { mockFetch } from '../helpers/mockRequests.js'; +import { URL } from 'node:url'; jest.mock('aws-xray-sdk-core', () => ({ ...jest.requireActual('aws-xray-sdk-core'), @@ -358,4 +362,243 @@ describe('Class: ProviderService', () => { expect(segmentSpy).toHaveBeenCalledWith('foo', 'bar', 'baz'); }); }); + + describe('Method: instrumentFetch', () => { + it('subscribes to the diagnostics channel', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + + // Act + provider.instrumentFetch(); + + // Assess + expect(channel('undici:request:create').hasSubscribers).toBe(true); + expect(channel('undici:request:headers').hasSubscribers).toBe(true); + expect(channel('undici:request:error').hasSubscribers).toBe(true); + }); + + it('traces a successful request', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); + + // Act + provider.instrumentFetch(); + mockFetch({ + origin: 'https://aws.amazon.com/blogs', + headers: { + 'content-length': '100', + }, + }); + + // Assess + expect(segment.addNewSubsegment).toHaveBeenCalledTimes(1); + expect(segment.addNewSubsegment).toHaveBeenCalledWith('aws.amazon.com'); + expect((subsegment as HttpSubsegment).http).toEqual({ + request: { + url: 'aws.amazon.com', + method: 'GET', + }, + response: { + status: 200, + content_length: 100, + }, + }); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); + }); + + it('excludes the content_length header when invalid or not found', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); + + // Act + provider.instrumentFetch(); + mockFetch({ + origin: new URL('https://aws.amazon.com/blogs'), + headers: { + 'content-type': 'application/json', + }, + }); + + // Assess + expect((subsegment as HttpSubsegment).http).toEqual({ + request: { + url: 'aws.amazon.com', + method: 'GET', + }, + response: { + status: 200, + }, + }); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); + }); + + it('adds a throttle flag to the segment when the status code is 429', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); + jest.spyOn(subsegment, 'addThrottleFlag'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); + + // Act + provider.instrumentFetch(); + mockFetch({ + origin: 'https://aws.amazon.com/blogs', + statusCode: 429, + }); + + // Assess + expect((subsegment as HttpSubsegment).http).toEqual( + expect.objectContaining({ + response: { + status: 429, + }, + }) + ); + expect(subsegment.addThrottleFlag).toHaveBeenCalledTimes(1); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); + }); + + it('adds an error flag to the segment when the status code is 4xx', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); + jest.spyOn(subsegment, 'addErrorFlag'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); + + // Act + provider.instrumentFetch(); + mockFetch({ + origin: 'https://aws.amazon.com/blogs', + statusCode: 404, + }); + + // Assess + expect((subsegment as HttpSubsegment).http).toEqual( + expect.objectContaining({ + response: { + status: 404, + }, + }) + ); + expect(subsegment.addErrorFlag).toHaveBeenCalledTimes(1); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); + }); + + it('adds a fault flag to the segment when the status code is 5xx', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); + jest.spyOn(subsegment, 'addFaultFlag'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); + + // Act + provider.instrumentFetch(); + mockFetch({ + origin: 'https://aws.amazon.com/blogs', + statusCode: 500, + }); + + // Assess + expect((subsegment as HttpSubsegment).http).toEqual( + expect.objectContaining({ + response: { + status: 500, + }, + }) + ); + expect(subsegment.addFaultFlag).toHaveBeenCalledTimes(1); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); + }); + }); + + it('closes the segment and adds a fault flag when the connection fails', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); + jest.spyOn(subsegment, 'addError'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); + + // Act + provider.instrumentFetch(); + try { + mockFetch({ + origin: 'https://aws.amazon.com/blogs', + throwError: true, + }); + } catch {} + + // Assess + expect(subsegment.addError).toHaveBeenCalledTimes(1); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); + }); }); diff --git a/packages/tracer/tests/unit/Tracer.test.ts b/packages/tracer/tests/unit/Tracer.test.ts index 04747e1f9f..f293717c58 100644 --- a/packages/tracer/tests/unit/Tracer.test.ts +++ b/packages/tracer/tests/unit/Tracer.test.ts @@ -12,7 +12,7 @@ import { setContextMissingStrategy, Subsegment, } from 'aws-xray-sdk-core'; -import type { ProviderServiceInterface } from '../../src/types/ProviderServiceInterface.js'; +import type { ProviderServiceInterface } from '../../src/types/ProviderService.js'; import type { ConfigServiceInterface } from '../../src/types/ConfigServiceInterface.js'; type CaptureAsyncFuncMock = jest.SpyInstance<