Skip to content

Observability

Three registries cover what you need to run Long Tail in production: telemetry exports the spans HotMesh already creates, logging routes every internal log line through one adapter, and maintenance keeps Postgres tables bounded on a durable schedule. Each is one option in start().

index.ts
await start({
database: { connectionString: process.env.DATABASE_URL },
workers: [/* ... */],
telemetry: { honeycomb: { apiKey: process.env.HONEYCOMB_API_KEY } },
logging: { pino: { level: 'info' } },
maintenance: true,
});

HotMesh creates OpenTelemetry spans for workflow triggers, activity calls, stream routing and errors. A telemetry adapter registers the global TracerProvider and exporter, and the spans flow. Workflow and activity code creates no spans of its own.

Option Default Use
apiKey process.env.HONEYCOMB_API_KEY Without one, connect() logs a warning and starts nothing
serviceName 'long-tail' The service.name on every span
endpoint 'https://api.honeycomb.io' Override for Honeycomb EU or a local collector

The adapter uses @opentelemetry/sdk-node with the OTLP proto exporter and sends to {endpoint}/v1/traces with the key in x-honeycomb-team.

Implement two methods:

telemetry/datadog.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import type { LTTelemetryAdapter } from '@hotmeshio/long-tail';
export class DatadogAdapter implements LTTelemetryAdapter {
private sdk: NodeSDK | null = null;
async connect() {
this.sdk = new NodeSDK({ /* exporter config */ });
this.sdk.start();
}
async disconnect() {
await this.sdk?.shutdown();
}
}

Pass it as telemetry: { adapter: new DatadogAdapter() }. The registry holds one adapter, because OpenTelemetry allows one global TracerProvider.

HMSH_TELEMETRY sets which spans HotMesh creates, independent of where they go. Set it on the containers that run workers.

Value Spans
info Workflow triggers, worker lifecycle, errors
debug Everything in info, plus every activity execution
unset None

To put trace_id and span_id on every log line, add @opentelemetry/instrumentation-pino to the NodeSDK inside your adapter’s connect().

Every internal log line goes through loggerRegistry. Register Pino, Winston, Bunyan or anything with four methods:

export interface LTLoggerAdapter {
info(msg: string, context?: Record<string, any>): void;
warn(msg: string, context?: Record<string, any>): void;
error(msg: string, context?: Record<string, any>): void;
debug(msg: string, context?: Record<string, any>): void;
}
await start({
database: { connectionString: process.env.DATABASE_URL },
logging: { pino: { level: 'info' } },
});

logging.pino takes Pino’s standard LoggerOptions: level, transports, redaction, serializers. Output is one JSON object per line. To register it yourself:

import { loggerRegistry, PinoLoggerAdapter } from '@hotmeshio/long-tail';
loggerRegistry.register(new PinoLoggerAdapter({
level: 'info',
transport: { target: 'pino-pretty' },
}));

The logging adapter registers before anything else, so startup messages flow through it. With no adapter, info and debug go to console.log, warn to console.warn and error to console.error.

HotMesh keeps stream messages, transient job rows and workflow rows in Postgres. Maintenance ages them out on a schedule that runs as a durable cron inside HotMesh, so a missed tick runs when a container comes back.

maintenance Effect
omitted or false No maintenance
true The default: Sundays at 2 AM, five rules
an LTMaintenanceConfig Your schedule and rules in place of the default
index.ts
await start({
database: { connectionString: process.env.DATABASE_URL },
workers: [/* ... */],
maintenance: {
appId: 'durable',
schedule: '0 3 * * *',
rules: [
{ target: 'streams', action: 'delete', olderThan: '24 hours' },
{ target: 'jobs', action: 'delete', olderThan: '14 days', hasEntity: false },
{ target: 'jobs', action: 'prune', olderThan: '14 days', hasEntity: true },
{ target: 'jobs', action: 'delete', olderThan: '180 days', pruned: true },
{ target: 'escalations', action: 'delete', olderThan: '180 days' },
{ target: 'oauth', action: 'delete', olderThan: '7 days' },
],
},
});
Target Rows
streams Internal messages HotMesh uses to coordinate activities
jobs with hasEntity: false Transient bookkeeping rows (entity IS NULL)
jobs with hasEntity: true Workflow instances. prune strips execution scaffolding and keeps the core fields, so search and export still work. delete removes the row.
jobs with pruned: true Workflow instances already pruned, for final deletion
escalations Terminal rows (resolved, cancelled, expired, or the statuses you list), deleted in atomic batches. Pending rows are never touched.
oauth Expired or rotated codes and refresh tokens, dead grants, and clients nobody consented to

The intended life of a workflow row: run, prune after a short window, delete after a long one. Rules run in order. A failed rule is logged and the rest still run.

{
appId: 'durable',
schedule: '0 2 * * 0',
rules: [
{ target: 'streams', action: 'delete', olderThan: '30 days' },
{ target: 'jobs', action: 'delete', olderThan: '30 days', hasEntity: false },
{ target: 'jobs', action: 'prune', olderThan: '30 days', hasEntity: true },
{ target: 'jobs', action: 'delete', olderThan: '180 days', pruned: true },
{ target: 'oauth', action: 'delete', olderThan: '7 days' },
],
}

olderThan is a Postgres interval ('7 days', '24 hours'). schedule is a cron expression or an interval string. Maintenance starts with the workers and does not run on a readonly instance.

Terminal window
curl https://lt.acme.dev/api/config/maintenance -H "Authorization: Bearer $LT_TOKEN"
curl -X PUT https://lt.acme.dev/api/config/maintenance \
-H "Authorization: Bearer $LT_TOKEN" -H 'Content-Type: application/json' \
-d '{
"appId": "durable",
"schedule": "0 4 * * *",
"rules": [
{ "target": "streams", "action": "delete", "olderThan": "3 days" },
{ "target": "jobs", "action": "prune", "olderThan": "3 days", "hasEntity": true }
]
}'

PUT is admin-only and returns { "restarted": true } with the new config, or 400 when appId, schedule or rules is missing. The new rules apply to ticks this container runs and live in memory: other containers keep their rules, and a restart returns to the start() config. An existing cron job keeps the schedule it was created with.