Skip to content

Commit 541c8cf

Browse files
chargomeclaude
andauthored
feat(server-utils): Add the orchestrion config for @neondatabase/serverless (#25176)
Adds the orchestrion config for `@neondatabase/serverless`, so the bundler plugins and the runtime hook can inject channels into it. The integration that subscribes to those channels follows in #25164 and registers the config there, which keeps every layer's CI green on its own. The package ships two minified esbuild bundles with mangled identifiers that differ between CJS and ESM, so the name-based matchers find nothing. The selectors key on property names and string literals that survive minification: pg's `_pulseQueryQueue` sibling method for `Client.prototype.query`, the `Neon-Connection-String` header literal for the HTTP executor, and the `resolvedURL` return shape for the connection resolver. `@neondatabase/serverless` becomes a dev dependency of server-utils for a test that transforms and executes the published bundles, so a Neon release that reshapes them fails CI. The version range is capped at `<2`. Stacked on #25163. Refs #25144 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9ef8a0e commit 541c8cf

4 files changed

Lines changed: 178 additions & 0 deletions

File tree

‎packages/server-utils/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
},
103103
"devDependencies": {
104104
"@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4",
105+
"@neondatabase/serverless": "1.2.0",
105106
"@types/node": "^18.19.1",
106107
"meriyah": "^6.1.4",
107108
"vite": "^8.3.1"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { InstrumentationConfig } from '../apmTypes';
2+
3+
import { getModuleNames } from './module-names';
4+
5+
// `@neondatabase/serverless` ships as two minified esbuild bundles (`index.js` for CJS,
6+
// `index.mjs` for ESM) that inline their own copy of `pg`. Class and function identifiers are
7+
// mangled and differ between the two builds, so the name-based `functionQuery` matchers can't
8+
// find anything. Every selector below keys on property names and string literals, which survive
9+
// minification and are identical in both bundles.
10+
const module = { name: '@neondatabase/serverless', versionRange: '>=1.0.0 <2', filePath: /^index\.m?js$/ };
11+
12+
export const neonConfig = [
13+
// The bundled pg `Client.prototype.query` (WebSocket driver). `_pulseQueryQueue` is a pg
14+
// internal that only `Client` defines, which keeps the pool and wire-protocol `query` methods
15+
// out. `methodName` is ignored for matching (`astQuery` wins) but makes the transformer emit a
16+
// method wrapper that exposes `self`, which carries `connectionParameters`.
17+
{
18+
channelName: 'query',
19+
module,
20+
astQuery:
21+
'ClassBody:has(MethodDefinition[key.name="_pulseQueryQueue"]) > MethodDefinition[key.name="query"] > FunctionExpression',
22+
functionQuery: { methodName: 'query', kind: 'Auto' },
23+
},
24+
// The HTTP executor nested inside `neon()`. Every HTTP query funnels through it: lazy
25+
// `NeonQueryPromise`s call it from `then`/`catch`/`finally`, and `transaction()` calls it once
26+
// with the whole batch, so wrapping it gives one span per request with no double counting.
27+
{
28+
channelName: 'http-query',
29+
module,
30+
astQuery: 'FunctionDeclaration[async=true]:has(Literal[value="Neon-Connection-String"])',
31+
functionQuery: { kind: 'Async' },
32+
},
33+
// `resolveConnectionParams`, awaited by the HTTP executor. The connection string lives in the
34+
// `neon()` closure and never reaches the executor's channel context, so the subscriber reads
35+
// `server.address`/`db.namespace` from this call's result instead, inside the query span.
36+
{
37+
channelName: 'resolve-connection',
38+
module,
39+
astQuery:
40+
'FunctionDeclaration[async=true]:has(ReturnStatement > ObjectExpression > Property[key.name="resolvedURL"])',
41+
functionQuery: { kind: 'Async' },
42+
},
43+
] satisfies InstrumentationConfig[];
44+
45+
export const neonModuleNames = getModuleNames(neonConfig);
46+
47+
export const neonChannels = {
48+
NEON_QUERY: 'orchestrion:@neondatabase/serverless:query',
49+
NEON_HTTP_QUERY: 'orchestrion:@neondatabase/serverless:http-query',
50+
NEON_RESOLVE_CONNECTION: 'orchestrion:@neondatabase/serverless:resolve-connection',
51+
} as const;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { createCodeTransformer } from '@apm-js-collab/code-transformer-bundler-plugins/core';
2+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3+
import { createRequire } from 'node:module';
4+
import { tmpdir } from 'node:os';
5+
import { dirname, join } from 'node:path';
6+
import { pathToFileURL } from 'node:url';
7+
import { tracingChannel } from 'node:diagnostics_channel';
8+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
9+
import { neonChannels, neonConfig } from '../../src/orchestrion/config/neon';
10+
11+
// The selectors key on internals of Neon's minified bundles, so this runs the bundler plugins'
12+
// code transformer over the real published files and executes the result: a release that
13+
// reshapes the bundle fails here rather than silently dropping spans.
14+
const nodeRequire = createRequire(import.meta.url);
15+
const packageDir = dirname(nodeRequire.resolve('@neondatabase/serverless'));
16+
17+
interface NeonModule {
18+
neon: (connectionString: string) => {
19+
(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown>;
20+
query: (text: string, params?: unknown[]) => Promise<unknown>;
21+
transaction: (queries: Promise<unknown>[]) => Promise<unknown>;
22+
};
23+
neonConfig: { fetchFunction: unknown };
24+
Client: new (connectionString: string) => {
25+
query: (text: string) => Promise<unknown>;
26+
end: () => Promise<void>;
27+
};
28+
}
29+
30+
function neonResponse(body: unknown): () => Promise<Response> {
31+
return () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }));
32+
}
33+
34+
const EMPTY_RESULT = { rows: [], fields: [], command: 'SELECT', rowCount: 0 };
35+
36+
// Every wrapper opens with a `hasSubscribers` guard on its channel, one per instrumented site.
37+
function countWrappedSites(code: string, channelVariable: string): number {
38+
return code.split(`tr_ch_apm_hasSubscribers(${channelVariable})`).length - 1;
39+
}
40+
41+
describe('neon orchestrion config on the published bundles', () => {
42+
let outDir: string;
43+
const events: string[] = [];
44+
45+
beforeAll(() => {
46+
outDir = mkdtempSync(join(tmpdir(), 'neon-bundle-'));
47+
for (const name of ['query', 'http-query', 'resolve-connection']) {
48+
tracingChannel(`orchestrion:@neondatabase/serverless:${name}`).subscribe({
49+
start: data => {
50+
const ctx = data as { arguments: unknown[]; self?: { connectionParameters?: { database?: string } } };
51+
events.push(`${name}:start:${ctx.self?.connectionParameters?.database ?? JSON.stringify(ctx.arguments[0])}`);
52+
},
53+
end: () => {},
54+
asyncStart: () => {},
55+
asyncEnd: data => {
56+
const ctx = data as { result?: { resolvedURL?: URL } };
57+
events.push(`${name}:asyncEnd:${ctx.result?.resolvedURL?.hostname ?? ''}`);
58+
},
59+
error: () => {},
60+
});
61+
}
62+
});
63+
64+
afterAll(() => rmSync(outDir, { recursive: true, force: true }));
65+
66+
it.each([
67+
['index.js', 'cjs'],
68+
['index.mjs', 'esm'],
69+
])('instruments both drivers in %s', async (file, format) => {
70+
const transformer = createCodeTransformer({ instrumentations: neonConfig });
71+
const source = join(packageDir, file);
72+
const result = transformer.transform(readFileSync(source, 'utf8'), source);
73+
expect(result).not.toBeNull();
74+
75+
for (const channel of Object.values(neonChannels)) {
76+
expect(result!.code).toContain(channel);
77+
}
78+
// Only pg's `Client.prototype.query` gets the channel, not the pool or wire-protocol `query`.
79+
expect(countWrappedSites(result!.code, 'tr_ch_apm$query')).toBe(1);
80+
expect(countWrappedSites(result!.code, 'tr_ch_apm$http_query')).toBe(1);
81+
expect(countWrappedSites(result!.code, 'tr_ch_apm$resolve_connection')).toBe(1);
82+
// The query wrapper exposes `self`, which carries pg's `connectionParameters`.
83+
const querySite = result!.code.indexOf('Client was passed a null or undefined query');
84+
expect(result!.code.slice(result!.code.lastIndexOf('__apm$ctx = {', querySite), querySite)).toContain('self: this');
85+
86+
const outFile = join(outDir, file);
87+
writeFileSync(outFile, result!.code);
88+
const mod: NeonModule =
89+
format === 'cjs' ? nodeRequire(outFile) : ((await import(pathToFileURL(outFile).href)) as NeonModule);
90+
91+
events.length = 0;
92+
mod.neonConfig.fetchFunction = neonResponse(EMPTY_RESULT);
93+
const sql = mod.neon('postgres://user:pass@ep-x.aws.neon.tech/neondb');
94+
await sql.query('SELECT $1', [1]);
95+
await sql`SELECT 2`;
96+
mod.neonConfig.fetchFunction = neonResponse({ results: [EMPTY_RESULT, EMPTY_RESULT] });
97+
await sql.transaction([sql`SELECT 3`, sql`SELECT 4`]);
98+
99+
const client = new mod.Client('postgres://user:pass@ep-x.aws.neon.tech/neondb');
100+
// Never connects: the channel fires at the call, before the socket is needed.
101+
client.query('SELECT 5').catch(() => {});
102+
await client.end().catch(() => {});
103+
104+
const resolve = [
105+
'resolve-connection:start:"postgres://user:pass@ep-x.aws.neon.tech/neondb"',
106+
'resolve-connection:asyncEnd:ep-x.aws.neon.tech',
107+
];
108+
expect(events).toEqual([
109+
'http-query:start:{"query":"SELECT $1","params":[1]}',
110+
...resolve,
111+
'http-query:asyncEnd:',
112+
'http-query:start:{"strings":["SELECT 2"],"values":[]}',
113+
...resolve,
114+
'http-query:asyncEnd:',
115+
'http-query:start:[{"strings":["SELECT 3"],"values":[]},{"strings":["SELECT 4"],"values":[]}]',
116+
...resolve,
117+
'http-query:asyncEnd:',
118+
'query:start:neondb',
119+
]);
120+
});
121+
});

‎yarn.lock‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5664,6 +5664,11 @@
56645664
"@emnapi/runtime" "^1.1.0"
56655665
"@tybys/wasm-util" "^0.9.0"
56665666

5667+
"@neondatabase/serverless@1.2.0":
5668+
version "1.2.0"
5669+
resolved "https://registry.yarnpkg.com/@neondatabase/serverless/-/serverless-1.2.0.tgz#d58553f0804eb9ddacb09662d607756b7dc74b77"
5670+
integrity sha512-tnaqgixUengZQNd9ZrS3ytQfamJzLKY8QxgqWQTNHHtB+KrFQWk77JTbbpg4RD+J3qE4eO2lVytvGDX5Kj6FdQ==
5671+
56675672
"@nestjs/common@^10.0.0":
56685673
version "10.4.15"
56695674
resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70"

0 commit comments

Comments
 (0)