diff --git a/apps/manifest.json b/apps/manifest.json index 33e8a77a5..04218fd4e 100644 --- a/apps/manifest.json +++ b/apps/manifest.json @@ -57,6 +57,13 @@ "description": "Instrument an app's LLM calls so they emit $ai_generation events", "ciCapable": true }, + { + "id": "metrics", + "dir": "metrics", + "label": "Metrics", + "description": "Instrument a service with posthog.metrics counters, gauges, and histograms", + "ciCapable": true + }, { "id": "skill", "dir": "misc", diff --git a/apps/metrics/README.md b/apps/metrics/README.md new file mode 100644 index 000000000..260694960 --- /dev/null +++ b/apps/metrics/README.md @@ -0,0 +1,26 @@ +# Metrics test apps + +Apps for testing `wizard metrics` (`posthog.metrics` counters, gauges, +histograms). Each app pins one starting state the verify step has to handle, +so together they cover install, upgrade, and reuse. + +Layout: `/-`, matching the pages +under [installation docs](https://posthog.com/docs/metrics/installation). + +## The apps + +- `python/flask-jobqueue` — PostHog installed at a **pre-metrics version** and + initialized without metrics config, plus an existing capture call. Tests the + upgrade path: bump the SDK, add `metrics={"service_name": ...}` to the + existing client, leave the capture call alone. +- `nodejs/express-orders` — **no PostHog at all**. Tests the fresh path: + install `posthog-node`, initialize with metrics config, instrument the + request middleware, the background job, and the external call. +- `nextjs/nextjs-storefront` — **full-stack with `posthog-js` only**. Tests + variant disambiguation: metrics measure service work, so the right pick is + the server variant (fresh `posthog-node` for the route handlers), not + bolting metrics onto the browser client. + +Every app has obvious choke points (request handling, a background job, an +external call, a business commit site) so instrumentation quality is +comparable across runs. diff --git a/apps/metrics/nextjs/nextjs-storefront/README.md b/apps/metrics/nextjs/nextjs-storefront/README.md new file mode 100644 index 000000000..3a32fa795 --- /dev/null +++ b/apps/metrics/nextjs/nextjs-storefront/README.md @@ -0,0 +1,21 @@ +# nextjs-storefront + +Minimal full-stack Next.js shop: a client page with `posthog-js` initialized, +and a checkout route handler that calls a payment API server-side. + +The disambiguation under test: metrics measure service work, so the right +variant is the **server** one — a fresh `posthog-node` client for the route +handler — not bolting metrics onto the existing browser client. + +Run: + +```bash +npm install +npm run dev +``` + +Traffic: open http://localhost:3000 and click "Buy a widget", or + +```bash +curl -X POST localhost:3000/api/checkout -H 'Content-Type: application/json' -d '{"item": "widget", "qty": 1}' +``` diff --git a/apps/metrics/nextjs/nextjs-storefront/app/api/checkout/route.ts b/apps/metrics/nextjs/nextjs-storefront/app/api/checkout/route.ts new file mode 100644 index 000000000..35937b5f2 --- /dev/null +++ b/apps/metrics/nextjs/nextjs-storefront/app/api/checkout/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; + +const PAYMENT_URL = 'https://httpbin.org/status/200'; + +let orderCount = 0; + +export async function POST(req: Request) { + const payload = await req.json(); + orderCount += 1; + try { + await fetch(PAYMENT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: orderCount, ...payload }), + }); + } catch { + return NextResponse.json( + { id: orderCount, status: 'payment-unreachable' }, + { status: 502 }, + ); + } + return NextResponse.json({ id: orderCount, status: 'paid' }); +} diff --git a/apps/metrics/nextjs/nextjs-storefront/app/layout.tsx b/apps/metrics/nextjs/nextjs-storefront/app/layout.tsx new file mode 100644 index 000000000..43f93a471 --- /dev/null +++ b/apps/metrics/nextjs/nextjs-storefront/app/layout.tsx @@ -0,0 +1,17 @@ +import { PostHogProvider } from './providers'; + +export const metadata = { title: 'Storefront' }; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/apps/metrics/nextjs/nextjs-storefront/app/page.tsx b/apps/metrics/nextjs/nextjs-storefront/app/page.tsx new file mode 100644 index 000000000..3b64af618 --- /dev/null +++ b/apps/metrics/nextjs/nextjs-storefront/app/page.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useState } from 'react'; + +export default function Home() { + const [status, setStatus] = useState(''); + + const checkout = async () => { + const res = await fetch('/api/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ item: 'widget', qty: 1 }), + }); + const body = await res.json(); + setStatus(`order ${body.id}: ${body.status}`); + }; + + return ( +
+

Storefront

+ +

{status}

+
+ ); +} diff --git a/apps/metrics/nextjs/nextjs-storefront/app/providers.tsx b/apps/metrics/nextjs/nextjs-storefront/app/providers.tsx new file mode 100644 index 000000000..c69091827 --- /dev/null +++ b/apps/metrics/nextjs/nextjs-storefront/app/providers.tsx @@ -0,0 +1,14 @@ +'use client'; + +import posthog from 'posthog-js'; +import { useEffect } from 'react'; + +export function PostHogProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY ?? 'phc_test_dummy_key', { + api_host: + process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com', + }); + }, []); + return <>{children}; +} diff --git a/apps/metrics/nextjs/nextjs-storefront/package.json b/apps/metrics/nextjs/nextjs-storefront/package.json new file mode 100644 index 000000000..3da6ac313 --- /dev/null +++ b/apps/metrics/nextjs/nextjs-storefront/package.json @@ -0,0 +1,21 @@ +{ + "name": "nextjs-storefront", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^15.1.0", + "posthog-js": "^1.200.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "20.17.6", + "@types/react": "19.2.18", + "typescript": "5.8.2" + } +} diff --git a/apps/metrics/nextjs/nextjs-storefront/tsconfig.json b/apps/metrics/nextjs/nextjs-storefront/tsconfig.json new file mode 100644 index 000000000..cae02ec53 --- /dev/null +++ b/apps/metrics/nextjs/nextjs-storefront/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/metrics/nodejs/express-orders/README.md b/apps/metrics/nodejs/express-orders/README.md new file mode 100644 index 000000000..3d9530fc9 --- /dev/null +++ b/apps/metrics/nodejs/express-orders/README.md @@ -0,0 +1,21 @@ +# express-orders + +Tiny order API with a background fulfillment loop. No PostHog anywhere — the +fresh path under test: install `posthog-node`, initialize one client with +`metrics: { serviceName }`, instrument the request handling, the background +job, and the external call. + +Run: + +```bash +npm install +npm start +``` + +Traffic: + +```bash +curl -X POST localhost:5002/orders -H 'Content-Type: application/json' -d '{"item": "widget", "qty": 2}' +curl localhost:5002/orders +curl localhost:5002/health +``` diff --git a/apps/metrics/nodejs/express-orders/package.json b/apps/metrics/nodejs/express-orders/package.json new file mode 100644 index 000000000..3f25c4c42 --- /dev/null +++ b/apps/metrics/nodejs/express-orders/package.json @@ -0,0 +1,12 @@ +{ + "name": "express-orders", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "express": "^4.19.0" + } +} diff --git a/apps/metrics/nodejs/express-orders/src/fulfillment.js b/apps/metrics/nodejs/express-orders/src/fulfillment.js new file mode 100644 index 000000000..b2b99ba2d --- /dev/null +++ b/apps/metrics/nodejs/express-orders/src/fulfillment.js @@ -0,0 +1,27 @@ +// Fulfillment queue: pending orders drain to an external API in the background. +const FULFILLMENT_URL = 'https://httpbin.org/status/200'; + +const pending = []; + +export function submitOrder(order) { + pending.push(order); +} + +export function pendingCount() { + return pending.length; +} + +export async function fulfillPending() { + while (pending.length > 0) { + const order = pending.shift(); + try { + await fetch(FULFILLMENT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(order), + }); + } catch { + // Fulfillment API unreachable: drop the order for this toy app. + } + } +} diff --git a/apps/metrics/nodejs/express-orders/src/server.js b/apps/metrics/nodejs/express-orders/src/server.js new file mode 100644 index 000000000..d338b3afa --- /dev/null +++ b/apps/metrics/nodejs/express-orders/src/server.js @@ -0,0 +1,38 @@ +// Order API: accepts orders over HTTP, retries fulfillment in a background loop. +import express from 'express'; + +import { fulfillPending, pendingCount, submitOrder } from './fulfillment.js'; + +const app = express(); +app.use(express.json()); + +const orders = []; + +app.post('/orders', (req, res) => { + const order = { + id: orders.length + 1, + item: req.body.item ?? 'unknown', + qty: Number(req.body.qty ?? 1), + createdAt: Date.now(), + }; + orders.push(order); + submitOrder(order); + res.status(201).json(order); +}); + +app.get('/orders', (_req, res) => { + res.json(orders); +}); + +app.get('/health', (_req, res) => { + res.json({ ok: true, pending: pendingCount() }); +}); + +// Background job: drain pending fulfillments every two seconds. +setInterval(() => { + fulfillPending().catch(() => {}); +}, 2000); + +app.listen(5002, () => { + console.log('express-orders listening on :5002'); +}); diff --git a/apps/metrics/python/flask-jobqueue/README.md b/apps/metrics/python/flask-jobqueue/README.md new file mode 100644 index 000000000..7ff068722 --- /dev/null +++ b/apps/metrics/python/flask-jobqueue/README.md @@ -0,0 +1,23 @@ +# flask-jobqueue + +Tiny order-processing service: HTTP API in, background worker out. + +PostHog is already here — `posthog==3.8.3` (pre-metrics) with a client +initialized in `app.py` and a capture call on order creation. The upgrade +path under test: bump the SDK past the metrics floor, add +`metrics={"service_name": ...}` to the existing client, touch nothing else. + +Run: + +```bash +pip install -r requirements.txt +python app.py +``` + +Traffic: + +```bash +curl -X POST localhost:5001/orders -H 'Content-Type: application/json' -d '{"item": "widget", "qty": 2}' +curl localhost:5001/orders +curl localhost:5001/health +``` diff --git a/apps/metrics/python/flask-jobqueue/app.py b/apps/metrics/python/flask-jobqueue/app.py new file mode 100644 index 000000000..f58336f55 --- /dev/null +++ b/apps/metrics/python/flask-jobqueue/app.py @@ -0,0 +1,54 @@ +"""Order API: accepts orders over HTTP, hands them to the background worker.""" + +import os +import time + +from flask import Flask, jsonify, request +from posthog import Posthog + +from worker import JobQueue + +app = Flask(__name__) + +posthog = Posthog( + os.environ.get("POSTHOG_API_KEY", "phc_test_dummy_key"), + host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"), +) + +queue = JobQueue() + +ORDERS: list[dict] = [] + + +@app.post("/orders") +def create_order(): + payload = request.get_json(force=True) + order = { + "id": len(ORDERS) + 1, + "item": payload.get("item", "unknown"), + "qty": int(payload.get("qty", 1)), + "created_at": time.time(), + } + ORDERS.append(order) + queue.submit(order) + posthog.capture( + distinct_id=f"user_{order['id'] % 7}", + event="order created", + properties={"item": order["item"], "qty": order["qty"]}, + ) + return jsonify(order), 201 + + +@app.get("/orders") +def list_orders(): + return jsonify(ORDERS) + + +@app.get("/health") +def health(): + return jsonify({"ok": True, "queued": queue.depth()}) + + +if __name__ == "__main__": + queue.start() + app.run(port=5001, debug=False) diff --git a/apps/metrics/python/flask-jobqueue/requirements.txt b/apps/metrics/python/flask-jobqueue/requirements.txt new file mode 100644 index 000000000..ef7b1050f --- /dev/null +++ b/apps/metrics/python/flask-jobqueue/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0.0 +posthog==3.8.3 +requests>=2.31.0 diff --git a/apps/metrics/python/flask-jobqueue/worker.py b/apps/metrics/python/flask-jobqueue/worker.py new file mode 100644 index 000000000..660efcdfc --- /dev/null +++ b/apps/metrics/python/flask-jobqueue/worker.py @@ -0,0 +1,35 @@ +"""Background worker: drains the order queue and notifies a fulfillment API.""" + +import queue +import threading +import time + +import requests + +FULFILLMENT_URL = "https://httpbin.org/status/200" + + +class JobQueue: + def __init__(self) -> None: + self._queue: queue.Queue = queue.Queue() + self._thread = threading.Thread(target=self._drain, daemon=True) + + def start(self) -> None: + self._thread.start() + + def submit(self, order: dict) -> None: + self._queue.put(order) + + def depth(self) -> int: + return self._queue.qsize() + + def _drain(self) -> None: + while True: + order = self._queue.get() + started = time.time() + try: + requests.post(FULFILLMENT_URL, json=order, timeout=5) + except requests.RequestException: + pass + time.sleep(max(0.0, 0.1 - (time.time() - started))) + self._queue.task_done()