Subscribe to cost events (cost:http:request, llm:generation:metering) to forward LLM and API spend to your own observability system. Use when adding cost/spend logging, building a cost observability integration, or forwarding per-request cost data to an external system.
Installs into .claude/skills of the current project.
Are you the author of Output Dev Cost Hooks?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/growthxai-output-dev-cost-hooks)
---
name: output-dev-cost-hooks
description: Subscribe to cost events (cost:http:request, llm:generation:metering) to forward LLM and API spend to your own observability system. Use when adding cost/spend logging, building a cost observability integration, or forwarding per-request cost data to an external system.
allowed-tools: [Read, Write, Edit, Glob]
---
# Cost Observability Hooks
## Overview
This skill documents how to subscribe to Output's cost events so every priced LLM call and every HTTP call with attached cost (via `addRequestCost`, see `output-dev-http-client-create`) can be forwarded to your own observability system — a webhook, a structured log pipeline, a metrics backend, etc. Handler errors are caught and logged by the framework; they never affect the workflow or the request that triggered them.
## When to Use This Skill
- Forwarding per-call spend to an external observability/webhook endpoint
- Logging cost events as structured fields for a log platform
- Building alerting or dashboards on top of workflow spend
- Auditing which workflows/activities are driving cost, in real time rather than after the fact
This skill is about **project-wide hook registration** for cost data already emitted by the framework. To make an HTTP client emit cost in the first place, see `output-dev-http-client-create`.
## Setup
### 1. Create a hook file
```typescript
// src/cost_hooks.ts
import { on } from '@outputai/core/hooks';
import type { HttpRequestCostEvent } from '@outputai/http';
import type { LLMGenerationMeteringEvent } from '@outputai/llm';
on<HttpRequestCostEvent>('cost:http:request', async event => {
// handle HTTP cost
});
on<LLMGenerationMeteringEvent>('llm:generation:metering', async event => {
// handle LLM cost
});
```
### 2. Register the file
Add the file to `outputai.hookFiles` in `package.json`, alongside any existing hook files. Paths are relative to the package root and point at the **built** `.js` output — `npm run output:worker:build` compiles `src/` to `dist/` (per `tsconfig.json`'s `rootDir`/`outDir`), so `src/cost_hooks.ts` is registered as `dist/cost_hooks.js`, not the `.ts` source itself. The worker loads these files at startup.
```json
{
"outputai": {
"hookFiles": [
"./dist/cost_hooks.js"
]
}
}
```
If you'd rather skip the build step, a hook file can also be plain, uncompiled JavaScript registered directly at its `src/` path (`"./src/cost_hooks.js"`) — see `https://docs.output.ai/operations/error-hooks` for that variant. The rest of this skill uses TypeScript + the built-output path, since that's what the framework's own examples (and every other file in a scaffolded project) use.
## Events You Can Subscribe To
| Event | Type import | When it fires | Prefer for |
|-------|-------------|----------------|-------------|
| `llm:generation:metering` | `LLMGenerationMeteringEvent` from `@outputai/llm` | After every LLM generation (text, image, Agent, streaming) that reports usage — including failed calls that got at least partial usage | New LLM cost integrations |
| `cost:llm:request` | `LLMUsageEvent` from `@outputai/llm` | Legacy/compatible LLM cost event, same completion path | Existing handlers only — do not use for new work |
| `cost:http:request` | `HttpRequestCostEvent` from `@outputai/http` | Only when your code (or a client's `afterResponse` hook) calls `addRequestCost(response, total)` | Non-LLM paid API calls |
Every event carries the same envelope: `eventId` (UUID v4, stable idempotency key), `eventDate` (ms epoch), `activityInfo` and `workflowDetails` (present when emitted from within a step/evaluator), `outputActivityKind`, and `payload` (the event-specific data described above).
## Pattern 1: Forward to an external endpoint
Use this when spend needs to reach an external observability system over HTTP. Forward the raw envelope plus payload; redact anything that might carry secrets (API keys or tokens embedded in query strings) before logging or sending the URL.
```typescript
// src/cost_hooks.ts
import { on } from '@outputai/core/hooks';
import { credentials } from '@outputai/core/credentials';
import type { HttpRequestCostEvent } from '@outputai/http';
import type { LLMGenerationMeteringEvent } from '@outputai/llm';
// Use plain fetch here, not createKyClient/outputFetch. Handlers run inside
// the emitting step's async context, so a traced client would add its own
// HTTP trace event to that step's trace on every forwarded cost event.
//
// Read the credential lazily with `get`, not `require`, at module scope: a
// hook file is imported at worker startup by an unguarded `await import()`
// (there's no try/catch around it), so a `require()` that throws here takes
// the whole worker down. `get`/`require` also only see workflow-scoped
// credentials from inside an activity — at startup, outside any activity,
// they resolve the global credential set only, so keep this in a global
// (not per-workflow) credential file.
const getWebhookUrl = (): string | undefined => credentials.get('observability.webhook_url') as string | undefined;
const postEvent = async (json: Record<string, unknown>): Promise<void> => {
const webhookUrl = getWebhookUrl();
if (!webhookUrl) {
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
await fetch(`${webhookUrl}/events`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(json),
signal: controller.signal
});
} finally {
clearTimeout(timeout);
}
};
// Strip query strings — some APIs put API keys or tokens there.
const redactUrl = (url: string): string => {
try {
const parsed = new URL(url);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return '[unparseable-url]';
}
};
on<HttpRequestCostEvent>('cost:http:request', async event => {
if (!event.workflowDetails || !event.payload) {
return;
}
// A caught failure here logs with this handler's own context; an
// uncaught one is still caught and logged by the framework either way.
try {
await postEvent({
eventId: event.eventId,
eventDate: event.eventDate,
workflowId: event.workflowDetails.workflowId,
kind: 'http',
url: redactUrl(event.payload.url),
totalUsd: event.payload.total
});
} catch (error) {
console.warn('cost_hooks: failed to forward HTTP cost event', error);
}
});
on<LLMGenerationMeteringEvent>('llm:generation:metering', async event => {
if (!event.workflowDetails || !event.payload) {
return;
}
try {
await postEvent({
eventId: event.eventId,
eventDate: event.eventDate,
workflowId: event.workflowDetails.workflowId,
kind: 'llm',
providerId: event.payload.usage.providerId,
modelId: event.payload.usage.modelId,
totalUsd: event.payload.cost?.total ?? null
});
} catch (error) {
console.warn('cost_hooks: failed to forward LLM cost event', error);
}
});
```
## Pattern 2: Structured logging
Use this when spend just needs to land in your log platform as structured facets, without a separate network call. Use the framework `Logger` so fields are emitted consistently with the rest of the worker's logs.
```typescript
// src/cost_hooks.ts
import { on } from '@outputai/core/hooks';
import { Logger } from '@outputai/core';
import type { HttpRequestCostEvent } from '@outputai/http';
import type { LLMGenerationMeteringEvent } from '@outputai/llm';
const log = Logger.createLogger('CostObservability');
const redactUrl = (url: string): string => {
try {
const parsed = new URL(url);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return '[unparseable-url]';
}
};
on<HttpRequestCostEvent>('cost:http:request', async event => {
if (!event.workflowDetails || !event.payload) {
return;
}
log.info('http_request_cost', {
eventId: event.eventId,
workflowId: event.workflowDetails.workflowId,
url: redactUrl(event.payload.url),
totalUsd: event.payload.total
});
});
on<LLMGenerationMeteringEvent>('llm:generation:metering', async event => {
if (!event.workflowDetails || !event.payload) {
return;
}
log.info('llm_generation_cost', {
eventId: event.eventId,
workflowId: event.workflowDetails.workflowId,
providerId: event.payload.usage.providerId,
modelId: event.payload.usage.modelId,
totalUsd: event.payload.cost?.total ?? null
});
});
```
## Handler Safety Rules
- **Handlers are wrapped in try/catch by the framework.** A thrown or rejected handler is caught and logged (e.g. `<eventName> hook error`) and never affects the workflow, the worker, or the request that triggered the event — the same guarantee `onError` handlers get. Adding your own `try/catch` is still worthwhile so a forwarding failure logs with context you control, but it's not required for safety.
- **Skip incomplete events.** Guard on `event.workflowDetails` and `event.payload` before using them — they're only populated when the event was emitted from within a step/evaluator context; `eventId` and `eventDate`, by contrast, are always present.
- **Redact before logging or sending URLs.** Some third-party APIs embed API keys or tokens in query strings. Strip the query string (and any path segments you know carry secrets) before it leaves the process.
- **Use `eventId` as an idempotency key** when forwarding to a system that might receive the same event more than once (e.g. retried delivery).
- **Expect one event per attempt.** A step or evaluator that retries emits one cost event per attempt — that's correct, since each attempt is a real billed call.
- **Prefer `llm:generation:metering` over `cost:llm:request`** for new integrations; the legacy event's shape is frozen and misses newer cost types (e.g. tool/grounding charges).
## Verification Checklist
- [ ] Hook file registered in `outputai.hookFiles` in `package.json`, pointing at the **built** `.js` path (`dist/...`), not the `.ts` source
- [ ] Hook file imports `on` from `@outputai/core/hooks`
- [ ] Handlers guard on missing `workflowDetails` / `payload`
- [ ] URLs are redacted (query string stripped) before logging or forwarding
- [ ] `llm:generation:metering` used instead of legacy `cost:llm:request` for new work
## Related Skills
- `output-dev-http-client-create` - Attaching cost to a paid API client with `addRequestCost`
- `output-dev-workflow-cost` - Post-hoc cost calculation for a single completed workflow run via the CLI
- `output-dev-credentials` - Storing the observability endpoint URL/token as a credential