Repository navigation
feat(node): metrics autocapture for HTTP, database and runtime metrics #5149
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
Changes from all commits
39704b3
2a6451d
99f6143
935f011
92291fc
f063dce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'posthog-node': minor | ||
| --- | ||
|
|
||
| Add `metrics.autocapture`: HTTP, database and runtime metrics from the official OpenTelemetry instrumentations, with no instrumentation code. Start the app with `node --import posthog-node/metrics/register` to measure every library it loads. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| "lint:fix": "oxlint --report-unused-disable-directives-severity error src --fix", | ||
| "test:unit": "vitest run", | ||
| "test:edge-compat": "vitest run --config vitest.edge.config.ts", | ||
| "test:built": "node --test scripts/check-metrics-register.mjs", | ||
| "prebuild": "node ../../tooling/write-version.mjs", | ||
| "build": "rslib build", | ||
| "dev": "rslib build -w", | ||
|
|
@@ -47,22 +48,52 @@ | |
| }, | ||
| "devDependencies": { | ||
| "@edge-runtime/vm": "^5.0.0", | ||
| "@opentelemetry/api": "^1.9.0", | ||
| "@opentelemetry/auto-instrumentations-node": "^0.80.0", | ||
| "@opentelemetry/exporter-metrics-otlp-proto": "^0.222.0", | ||
| "@opentelemetry/resources": "^2.11.0", | ||
| "@opentelemetry/sdk-metrics": "^2.11.0", | ||
| "@opentelemetry/sdk-trace-base": "^2.10.0", | ||
| "@posthog-tooling/tsconfig-base": "workspace:*", | ||
| "@posthog/types": "workspace:^", | ||
| "@rslib/core": "catalog:", | ||
| "@types/express": "^5.0.6", | ||
| "@types/node": "^20.0.0", | ||
| "rxjs": "^7.8.0", | ||
| "@vitest/coverage-v8": "catalog:", | ||
| "vitest": "catalog:", | ||
| "typescript": "catalog:native" | ||
| "rxjs": "^7.8.0", | ||
| "typescript": "catalog:native", | ||
| "vitest": "catalog:" | ||
| }, | ||
| "peerDependencies": { | ||
| "rxjs": "^7.0.0" | ||
| "rxjs": "^7.0.0", | ||
| "@opentelemetry/api": "^1.9.0", | ||
| "@opentelemetry/sdk-metrics": "^2.0.0", | ||
| "@opentelemetry/sdk-trace-base": "^2.0.0", | ||
| "@opentelemetry/resources": "^2.0.0", | ||
|
Comment on lines
+70
to
+72
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 · Preserve installation compatibility with OpenTelemetry 1.x Applications with direct OpenTelemetry 1.x dependencies now encounter npm peer-resolution conflicts when upgrading |
||
| "@opentelemetry/exporter-metrics-otlp-proto": ">=0.200.0 <1", | ||
| "@opentelemetry/auto-instrumentations-node": ">=0.80.0 <1" | ||
| }, | ||
| "peerDependenciesMeta": { | ||
| "rxjs": { | ||
| "optional": true | ||
| }, | ||
| "@opentelemetry/api": { | ||
| "optional": true | ||
| }, | ||
| "@opentelemetry/sdk-metrics": { | ||
| "optional": true | ||
| }, | ||
| "@opentelemetry/sdk-trace-base": { | ||
| "optional": true | ||
| }, | ||
| "@opentelemetry/resources": { | ||
| "optional": true | ||
| }, | ||
| "@opentelemetry/exporter-metrics-otlp-proto": { | ||
| "optional": true | ||
| }, | ||
| "@opentelemetry/auto-instrumentations-node": { | ||
| "optional": true | ||
| } | ||
| }, | ||
| "keywords": [ | ||
|
|
@@ -113,6 +144,12 @@ | |
| "import": "./dist/experimental.mjs", | ||
| "require": "./dist/experimental.js", | ||
| "default": "./dist/experimental.js" | ||
| }, | ||
| "./metrics/register": { | ||
| "types": "./dist/entrypoints/metrics-register.node.d.ts", | ||
| "import": "./dist/entrypoints/metrics-register.node.mjs", | ||
| "require": "./dist/entrypoints/metrics-register.node.js", | ||
| "default": "./dist/entrypoints/metrics-register.node.js" | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| // Runs the built `posthog-node/metrics/register` entrypoint the documented way | ||
| // (`node --import`) against a fake PostHog host. Covers what in-process tests | ||
| // cannot: the package export, the ESM loader hook and the flush on SIGTERM. | ||
| import assert from 'node:assert/strict' | ||
| import { spawn } from 'node:child_process' | ||
| import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | ||
| import { createServer } from 'node:http' | ||
| import { tmpdir } from 'node:os' | ||
| import path from 'node:path' | ||
| import { test } from 'node:test' | ||
| import { fileURLToPath } from 'node:url' | ||
|
|
||
| const packageDir = fileURLToPath(new URL('..', import.meta.url)) | ||
|
|
||
| const APP = ` | ||
| import { createServer } from 'node:http' | ||
|
|
||
| const server = createServer((_request, response) => response.end('ok')) | ||
| server.listen(0, '127.0.0.1', async () => { | ||
| const { port } = server.address() | ||
| await fetch('http://127.0.0.1:' + port + '/') | ||
| console.log('ready') | ||
| }) | ||
| ` | ||
|
|
||
| async function startFakePostHog(t) { | ||
| const requests = [] | ||
| const server = createServer((request, response) => { | ||
| const chunks = [] | ||
| request.on('data', (chunk) => chunks.push(chunk)) | ||
| request.on('end', () => { | ||
| requests.push({ | ||
| method: request.method, | ||
| url: request.url, | ||
| authorization: request.headers.authorization, | ||
| body: Buffer.concat(chunks), | ||
| }) | ||
| response.end() | ||
| }) | ||
| }) | ||
| await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) | ||
| t.after(() => server.close()) | ||
| return { requests, host: `http://127.0.0.1:${server.address().port}` } | ||
| } | ||
|
|
||
| test('flushes autocaptured metrics to the host when the process gets SIGTERM', async (t) => { | ||
| const fakePostHog = await startFakePostHog(t) | ||
| const dir = mkdtempSync(path.join(tmpdir(), 'posthog-metrics-register-')) | ||
| t.after(() => rmSync(dir, { recursive: true, force: true })) | ||
| const app = path.join(dir, 'app.mjs') | ||
| writeFileSync(app, APP) | ||
|
|
||
| // Resolved from the package directory, so the package's own `exports` and its | ||
| // OpenTelemetry dev dependencies are used. | ||
| const child = spawn(process.execPath, ['--import', 'posthog-node/metrics/register', app], { | ||
| cwd: packageDir, | ||
| env: { | ||
| ...process.env, | ||
| POSTHOG_PROJECT_TOKEN: 'phc_test', | ||
| // The trailing slash must not change the export path. | ||
| POSTHOG_HOST: `${fakePostHog.host}/`, | ||
| OTEL_SERVICE_NAME: 'metrics-register-test', | ||
| }, | ||
| stdio: ['ignore', 'pipe', 'pipe'], | ||
| }) | ||
| let stderr = '' | ||
| child.stderr.on('data', (chunk) => (stderr += chunk)) | ||
| const exited = new Promise((resolve) => child.on('exit', (code, signal) => resolve({ code, signal }))) | ||
| t.after(() => child.kill('SIGKILL')) | ||
|
|
||
| await new Promise((resolve, reject) => { | ||
| child.stdout.on('data', (chunk) => String(chunk).includes('ready') && resolve()) | ||
| child.on('exit', () => reject(new Error(`app exited before it was ready: ${stderr}`))) | ||
| }) | ||
| // The export interval is 10s, so only the shutdown flush can send before this. | ||
| child.kill('SIGTERM') | ||
|
|
||
| assert.deepEqual(await exited, { code: null, signal: 'SIGTERM' }, stderr) | ||
| const exports = fakePostHog.requests.filter((request) => request.url === '/i/v1/metrics') | ||
| assert.ok(exports.length > 0, `requests: ${JSON.stringify(fakePostHog.requests.map((r) => r.url))}`) | ||
| for (const metricsExport of exports) { | ||
| assert.equal(metricsExport.method, 'POST') | ||
| assert.equal(metricsExport.authorization, 'Bearer phc_test') | ||
| } | ||
| const body = Buffer.concat(exports.map((metricsExport) => metricsExport.body)).toString('latin1') | ||
| // Protobuf keeps strings as raw bytes. | ||
| for (const expected of [ | ||
| 'metrics-register-test', | ||
| 'posthog-node', | ||
| 'http.server.request.duration', | ||
| 'http.client.request.duration', | ||
| ]) { | ||
| assert.ok(body.includes(expected), `export is missing ${expected}`) | ||
| } | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.