Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/posthog-node-metrics-autocapture.md
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.
45 changes: 41 additions & 4 deletions packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Comment thread
DanielVisca marked this conversation as resolved.
"@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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 posthog-node, even with autocapture disabled. Marking these peers optional permits their absence but still enforces the ^2.0.0 ranges when installed. This makes the minor release incompatible with existing setups; isolate the version requirements to the opt-in integration.

"@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": [
Expand Down Expand Up @@ -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"
}
}
}
120 changes: 98 additions & 22 deletions packages/node/references/posthog-node-references-latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,28 @@
},
"path": "src/entrypoints/index.node.ts"
},
{
"category": "Privacy",
"description": "Opt the client back in (same as `enable()`). Autocaptured metrics recorded while opted out are dropped, not sent.",
"details": null,
"id": "optIn",
"showDocs": true,
"title": "optIn",
"examples": [
{
"id": "optin",
"name": "Generated example for optIn",
"code": "// Generated example for optIn\nposthog.optIn();"
}
],
"releaseTag": "public",
"params": [],
"returnType": {
"id": "Promise<void>",
"name": "Promise<void>"
},
"path": "src/entrypoints/index.node.ts"
},
{
"category": "Feature flags",
"description": "Override feature flags locally. Useful for testing and local development. Overridden flags take precedence over both local evaluation and remote evaluation.",
Expand Down Expand Up @@ -1629,27 +1651,6 @@
},
"path": "src/entrypoints/index.node.ts"
},
{
"category": "",
"details": null,
"id": "optIn",
"showDocs": true,
"title": "optIn",
"examples": [
{
"id": "optin",
"name": "Generated example for optIn",
"code": "// Generated example for optIn\nposthog.optIn();"
}
],
"releaseTag": "public",
"params": [],
"returnType": {
"id": "Promise<void>",
"name": "Promise<void>"
},
"path": "src/entrypoints/index.node.ts"
},
{
"category": "",
"details": null,
Expand Down Expand Up @@ -2805,6 +2806,34 @@
],
"path": "../core/src/error-tracking/types.ts"
},
{
"id": "MetricsAutocaptureConfig",
"name": "MetricsAutocaptureConfig",
"properties": [
{
"description": "`http.server.request.duration` and `http.client.request.duration` for `http`, `https` and `fetch`.",
"type": "boolean",
"name": "http"
},
{
"description": "`db.client.operation.duration` and connection metrics for pg, mysql, mysql2, mongodb, redis, ioredis and more.",
"type": "boolean",
"name": "db"
},
{
"description": "Event loop, heap and garbage collection metrics.",
"type": "boolean",
"name": "runtime"
}
],
"path": "src/types.ts"
},
{
"id": "MetricsAutocaptureHandle",
"name": "MetricsAutocaptureHandle",
"properties": [],
"path": "src/client.ts"
},
{
"id": "MiddlewareError",
"name": "MiddlewareError",
Expand Down Expand Up @@ -2906,6 +2935,53 @@
],
"path": "../core/src/types.ts"
},
{
"id": "NodeMetricsConfig",
"name": "NodeMetricsConfig",
"properties": [
{
"description": "The service name for metric series.\nMaps to the OTel resource attribute 'service.name'.",
"type": "string",
"name": "serviceName"
},
{
"description": "The deployment environment for metric series (e.g. 'production', 'staging').\nMaps to the OTel resource attribute 'deployment.environment'.",
"type": "string",
"name": "environment"
},
{
"description": "The service version for metric series (e.g. '1.2.3').\nMaps to the OTel resource attribute 'service.version'.",
"type": "string",
"name": "serviceVersion"
},
{
"description": "Additional resource attributes applied to every metrics batch.\nThese describe the service/deployment, not individual series.\nNamed fields (serviceName, environment, serviceVersion) are set first;\nresourceAttributes can override them.",
"type": "MetricAttributes",
"name": "resourceAttributes"
},
{
"description": "How often the aggregated window is flushed, in milliseconds. Samples\nare folded into per-series aggregates in memory between flushes — one\ndata point per series per window, no matter how many calls.",
"type": "number",
"name": "flushIntervalMs"
},
{
"description": "Cardinality guardrail: maximum distinct series (name + type + unit +\nattribute combination) held per flush window. Samples for series\nbeyond the cap are dropped with a single warning per window.",
"type": "number",
"name": "maxSeriesPerFlush"
},
{
"description": "Pre-aggregation filter for metric samples, as a single function or a\nleft-to-right chain. Each function inspects, mutates, or drops a\nsample (return `null` to drop) before it is aggregated.",
"type": "BeforeSendMetricFn | BeforeSendMetricFn[]",
"name": "beforeSend"
},
{
"description": "Capture HTTP, database and runtime metrics with no instrumentation code, using\nthe official OpenTelemetry instrumentations. Needs the OpenTelemetry peer\npackages (`npm install",
"type": "boolean | MetricsAutocaptureConfig",
"name": "autocapture"
}
],
"path": "src/types.ts"
},
{
"id": "ObjectLike",
"name": "ObjectLike",
Expand Down Expand Up @@ -3707,7 +3783,7 @@
},
{
"description": "Configuration for the `posthog.metrics` API (count, gauge, histogram).\nSet `serviceName` so series can be filtered per service in the Metrics UI.",
"type": "MetricsConfig",
"type": "NodeMetricsConfig",
"name": "metrics"
},
{
Expand Down
95 changes: 95 additions & 0 deletions packages/node/scripts/check-metrics-register.mjs
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}`)
}
})
Loading
Loading