Skip to content

feat(server): New Instruments API #2068

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
aed1821
feat(server): New Tracer API
EmrysMyrddin Feb 18, 2025
6de524b
Tracer is not mandatory
EmrysMyrddin Feb 18, 2025
f2908fb
lint
EmrysMyrddin Feb 18, 2025
eaffa1e
changeset
EmrysMyrddin Feb 18, 2025
5a8065c
all wrappers are optional
EmrysMyrddin Feb 18, 2025
9627f0b
fix optional tracer request
EmrysMyrddin Feb 18, 2025
07826f7
rename to instruments and use instruments utils
EmrysMyrddin Feb 27, 2025
695a2ba
chore(dependencies): updated changesets for modified dependencies
github-actions[bot] Feb 27, 2025
229f8b7
prettier
EmrysMyrddin Feb 27, 2025
ee7c3b8
use alpha version of instruments utils
EmrysMyrddin Feb 27, 2025
2f22410
chore(dependencies): updated changesets for modified dependencies
github-actions[bot] Feb 27, 2025
319c9d4
update instruments utils
EmrysMyrddin Feb 27, 2025
36012c9
chore(dependencies): updated changesets for modified dependencies
github-actions[bot] Feb 27, 2025
fc4cd52
add build step to the deno tests
EmrysMyrddin Feb 27, 2025
68350f4
Fix Deno imports
ardatan Feb 27, 2025
1796ef3
Add TSDocs
EmrysMyrddin Feb 28, 2025
c7d8041
add unit tests
EmrysMyrddin Mar 3, 2025
a96b225
chore(dependencies): updated changesets for modified dependencies
github-actions[bot] Mar 3, 2025
04ffb29
fix iterateAsync type
EmrysMyrddin Mar 3, 2025
84e9572
changeset
EmrysMyrddin Mar 3, 2025
86f6ed6
seriously TS...
EmrysMyrddin Mar 3, 2025
c519812
use @envelop/instruments released version
EmrysMyrddin Mar 4, 2025
bd9cf2e
chore(dependencies): updated changesets for modified dependencies
github-actions[bot] Mar 4, 2025
cac420c
update changeset
EmrysMyrddin Mar 4, 2025
0dac2e5
fix typos
EmrysMyrddin Mar 4, 2025
9e59299
fix changeset version range
EmrysMyrddin Mar 4, 2025
72a41e0
fix changeset
EmrysMyrddin Mar 4, 2025
5149d67
re-export instruments utils
EmrysMyrddin Mar 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/@whatwg-node_server-2068-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@whatwg-node/server": patch
---
dependencies updates:
- Updated dependency [`@whatwg-node/promise-helpers@^1.2.2` ↗︎](https://www.npmjs.com/package/@whatwg-node/promise-helpers/v/1.2.2) (from `^1.0.0`, in `dependencies`)
- Added dependency [`@envelop/[email protected]` ↗︎](https://www.npmjs.com/package/@envelop/instruments/v/1.0.0) (to `dependencies`)
95 changes: 95 additions & 0 deletions .changeset/purple-buses-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
'@whatwg-node/server': minor
---

Add new Instruments API

Introduction of a new API allowing to instrument the graphql pipeline.

This new API differs from already existing Hooks by not having access to input/output of phases. The
goal of `Instruments` is to run allow running code before, after or around the **whole process of a
phase**, including plugins hooks executions.

The main use case of this new API is observability (monitoring, tracing, etc...).

### Basic usage

```ts
import Sentry from '@sentry/node'
import { createServerAdapter } from '@whatwg-node/server'

const server = createServerAdapter(
(req, res) => {
//...
},
{
plugins: [
{
instruments: {
request: ({ request }, wrapped) =>
Sentry.startSpan({ name: 'Graphql Operation' }, async () => {
try {
await wrapped()
} catch (err) {
Sentry.captureException(err)
}
})
}
}
]
}
)
```

### Multiple instruments plugins

It is possible to have multiple instruments plugins (Prometheus and Sentry for example), they will
be automatically composed by envelop in the same order than the plugin array (first is outermost,
last is inner most).

```ts
import { createServerAdapter } from '@whatwg-node/server'

const server = createServerAdapter(
(req, res) => {
//...
},
{ plugins: [useSentry(), useOpentelemetry()] }
)
```

```mermaid
sequenceDiagram
Sentry->>Opentelemetry: ;
Opentelemetry->>Server Adapter: ;
Server Adapter->>Opentelemetry: ;
Opentelemetry->>Sentry: ;
```

### Custom instruments ordering

If the default composition ordering doesn't suite your need, you can manually compose instruments.
This allows to have a different execution order of hooks and instruments.

```ts
import { composeInstruments, createServerAdapter } from '@whatwg-node/server'

const { instruments: sentryInstruments, ...sentryPlugin } = useSentry()
const { instruments: otelInstruments, ...otelPlugin } = useOpentelemetry()
const instruments = composeInstruments([otelInstruments, sentryInstruments])

const server = createServerAdapter(
(req, res) => {
//...
},
{ plugins: [{ instruments }, sentryPlugin, otelPlugin] }
)
```

```mermaid
sequenceDiagram
Opentelemetry->>Sentry: ;
Sentry->>Server Adapter: ;
Server Adapter->>Sentry: ;
Sentry->>Opentelemetry: ;
```
6 changes: 6 additions & 0 deletions .changeset/tasty-berries-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@whatwg-node/promise-helpers': patch
---

Fix return type of the callback of `iterateAsync`. The callback can actually return `null` or
`undefined`, the implementation is already handling this case.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,6 @@ package-lock.json
eslint_report.json

deno.lock
.helix/config.toml
.helix/languages.toml
.mise.toml
10 changes: 1 addition & 9 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
{
"imports": {
"@jest/globals": "./deno-jest.ts",
"@whatwg-node/cookie-store": "./packages/cookie-store/src/index.ts",
"@whatwg-node/disposablestack": "./packages/disposablestack/src/index.ts",
"@whatwg-node/fetch": "./packages/fetch/dist/esm-ponyfill.js",
"@whatwg-node/events": "./packages/events/src/index.ts",
"fetchache": "./packages/fetchache/src/index.ts",
"@whatwg-node/node-fetch": "./packages/node-fetch/src/index.ts",
"@whatwg-node/server": "./packages/server/src/index.ts",
"@whatwg-node/server-plugin-cookies": "./packages/server-plugin-cookies/src/index.ts",
"@whatwg-node/promise-helpers": "./packages/promise-helpers/src/index.ts"
"@whatwg-node/fetch": "./packages/fetch/dist/esm-ponyfill.js"
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"prebuild": "yarn clean-dist",
"prerelease": "yarn build",
"prerelease-canary": "yarn build",
"pretest:deno": "yarn build",
"prettier": "prettier --ignore-path .gitignore --ignore-path .prettierignore --write --list-different .",
"prettier:check": "prettier --ignore-path .gitignore --ignore-path .prettierignore --check .",
"release": "changeset publish",
Expand Down
6 changes: 5 additions & 1 deletion packages/promise-helpers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ export { iterateAsync as iterateAsyncVoid };

export function iterateAsync<TInput, TOutput>(
iterable: Iterable<TInput>,
callback: (input: TInput, endEarly: VoidFunction, index: number) => MaybePromise<TOutput>,
callback: (
input: TInput,
endEarly: VoidFunction,
index: number,
) => MaybePromise<TOutput | undefined | null | void>,
results?: TOutput[],
): MaybePromise<void> {
if ((iterable as Array<TInput>)?.length === 0) {
Expand Down
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
},
"typings": "dist/typings/index.d.ts",
"dependencies": {
"@envelop/instruments": "1.0.0",
"@whatwg-node/disposablestack": "^0.0.6",
"@whatwg-node/fetch": "^0.10.5",
"@whatwg-node/promise-helpers": "^1.0.0",
Expand Down
24 changes: 22 additions & 2 deletions packages/server/src/createServerAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { chain, getInstrumented } from '@envelop/instruments';
import { AsyncDisposableStack, DisposableSymbols } from '@whatwg-node/disposablestack';
import * as DefaultFetchAPI from '@whatwg-node/fetch';
import { handleMaybePromise, MaybePromise } from '@whatwg-node/promise-helpers';
import { OnRequestHook, OnResponseHook, ServerAdapterPlugin } from './plugins/types.js';
import {
Instruments,
OnRequestHook,
OnResponseHook,
ServerAdapterPlugin,
} from './plugins/types.js';
import {
FetchAPI,
FetchEvent,
Expand Down Expand Up @@ -102,6 +108,7 @@ function createServerAdapter<

const onRequestHooks: OnRequestHook<TServerContext & ServerAdapterInitialContext>[] = [];
const onResponseHooks: OnResponseHook<TServerContext & ServerAdapterInitialContext>[] = [];
let instruments: Instruments | undefined;
const waitUntilPromises = new Set<PromiseLike<unknown>>();
let _disposableStack: AsyncDisposableStack | undefined;
function ensureDisposableStack() {
Expand Down Expand Up @@ -145,6 +152,9 @@ function createServerAdapter<

if (options?.plugins != null) {
for (const plugin of options.plugins) {
if (plugin.instruments) {
instruments = instruments ? chain(instruments, plugin.instruments) : plugin.instruments;
}
if (plugin.onRequest) {
onRequestHooks.push(plugin.onRequest);
}
Expand All @@ -165,7 +175,7 @@ function createServerAdapter<
}
}

const handleRequest: ServerAdapterRequestHandler<TServerContext & ServerAdapterInitialContext> =
let handleRequest: ServerAdapterRequestHandler<TServerContext & ServerAdapterInitialContext> =
onRequestHooks.length > 0 || onResponseHooks.length > 0
? function handleRequest(request, serverContext) {
let requestHandler: ServerAdapterRequestHandler<any> = givenHandleRequest;
Expand Down Expand Up @@ -238,6 +248,16 @@ function createServerAdapter<
}
: givenHandleRequest;

if (instruments?.request) {
const originalRequestHandler = handleRequest;
handleRequest = (request, initialContext) => {
return getInstrumented({ request }).asyncFn(instruments.request, originalRequestHandler)(
request,
initialContext,
);
};
}

// TODO: Remove this on the next major version
function handleNodeRequest(nodeRequest: NodeRequest, ...ctx: Partial<TServerContext>[]) {
const serverContext = ctx.length > 1 ? completeAssign(...ctx) : ctx[0] || {};
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './plugins/useContentEncoding.js';
export * from './uwebsockets.js';
export { Response } from '@whatwg-node/fetch';
export { DisposableSymbols } from '@whatwg-node/disposablestack';
export * from '@envelop/instruments';
20 changes: 20 additions & 0 deletions packages/server/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import {
} from '../types.js';

export interface ServerAdapterPlugin<TServerContext = {}> {
/**
* A tracer insance. It can be used to wrap the entire request handling pipeline (including the
* plugin hooks). It is mostly used for observability (monitoring, tracing, etc...).
*/
instruments?: Instruments;
/**
* This hook is invoked for ANY incoming HTTP request. Here you can manipulate the request,
* create a short circuit before the request handler takes it over.
Expand Down Expand Up @@ -43,6 +48,21 @@ export interface ServerAdapterPlugin<TServerContext = {}> {
*/
onDispose?: () => PromiseLike<void> | void;
}

export type Instruments = {
/**
* Run code befor, after or around the handling of each request.
* This instrument can't modify result or paramters of the request handling.
* To have access to the input or the output of the request handling, use the `onRequest` hook.
*
* Note: The `wrapped` function must be called, otherwise the request will not be handled properly
*/
request?: (
payload: { request: Request },
wrapped: () => Promise<void> | void,
) => Promise<void> | void;
};

export type OnRequestHook<TServerContext> = (
payload: OnRequestEventPayload<TServerContext>,
) => Promise<void> | void;
Expand Down
33 changes: 33 additions & 0 deletions packages/server/test/instruments.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from '@jest/globals';
import { createServerAdapter, ServerAdapterPlugin } from '@whatwg-node/server';

describe('instruments', () => {
it('should wrap request handler with instruments and automatically compose them', async () => {
const results: string[] = [];

function make(name: string): ServerAdapterPlugin {
return {
instruments: {
request: async (_, wrapped) => {
results.push(`pre-${name}`);
await wrapped();
results.push(`post-${name}`);
},
},
};
}

const adapter = createServerAdapter<{}>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace empty object type with a more specific type.

Using {} as a type is discouraged as it means "any non-nullable value" rather than "empty object" as often expected. Consider using a more specific type or unknown if the generic type parameter isn't important for this test.

-const adapter = createServerAdapter<{}>(
+const adapter = createServerAdapter<unknown>(

Alternative solutions depending on the actual requirements:

// If you need to specify an empty object type:
const adapter = createServerAdapter<Record<string, never>>(

// Or if there's a specific context type that should be used:
const adapter = createServerAdapter<YourContextType>(
🧰 Tools
🪛 Biome (1.9.4)

[error] 20-20: Don't use '{}' as a type.

Prefer explicitly define the object shape. '{}' means "any non-nullable value".

(lint/complexity/noBannedTypes)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace empty object type with a more specific type

Using {} as a type is discouraged as it means "any non-nullable value" rather than "empty object". It's better to explicitly define the object shape or use a more appropriate type.

- const adapter = createServerAdapter<{}>(
+ const adapter = createServerAdapter<Record<string, never>>(

Alternatively, if there's a specific context type that should be used here, consider using that instead.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const adapter = createServerAdapter<{}>(
const adapter = createServerAdapter<Record<string, never>>(
🧰 Tools
🪛 Biome (1.9.4)

[error] 20-20: Don't use '{}' as a type.

Prefer explicitly define the object shape. '{}' means "any non-nullable value".

(lint/complexity/noBannedTypes)

() => {
results.push('request');
return Response.json({ message: 'Hello, World!' });
},
{
plugins: [make('1'), make('2'), make('3')],
},
);

await adapter.fetch('http://whatwg-node/graphql');
expect(results).toEqual(['pre-1', 'pre-2', 'pre-3', 'request', 'post-3', 'post-2', 'post-1']);
});
});
12 changes: 9 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,14 @@
dependencies:
tslib "^2.4.0"

"@envelop/[email protected]":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@envelop/instruments/-/instruments-1.0.0.tgz#7e36926b6212048258ce1939bcf5a52e2a5dbe4d"
integrity sha512-f4lHoti7QgUIluIGTM0mG9Wf9/w6zc1mosYmyFkrApeHSP2PIUC6a8fMoqkdk6pgVOps39kLdIhOPF8pIKS8/A==
dependencies:
"@whatwg-node/promise-helpers" "^1.2.1"
tslib "^2.5.0"

"@esbuild-plugins/[email protected]":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz#0e4497a2b53c9e9485e149bc92ddb228438d6bcf"
Expand Down Expand Up @@ -7700,7 +7708,6 @@ mvdan-sh@^0.10.1:

"nan@github:JCMais/nan#fix/electron-failures":
version "2.22.0"
uid "0ec2eca8b2fd7518affb3945d087e393ad839b7e"
resolved "https://codeload.github.com/JCMais/nan/tar.gz/0ec2eca8b2fd7518affb3945d087e393ad839b7e"

nanoid@^3.3.6:
Expand Down Expand Up @@ -9767,7 +9774,7 @@ [email protected]:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==

tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.8.0:
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
Expand Down Expand Up @@ -9873,7 +9880,6 @@ [email protected]:

uWebSockets.js@uNetworking/uWebSockets.js#v20.51.0:
version "20.51.0"
uid "6609a88ffa9a16ac5158046761356ce03250a0df"
resolved "https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/6609a88ffa9a16ac5158046761356ce03250a0df"

ufo@^1.5.4:
Expand Down
Loading