LogTide

Lifecycle Hooks

Lifecycle hooks are a small, intentional set of named extension points inside the backend. Each phase is a specific moment in a specific code path where a registered handler may observe an operation, and on before phases also mutate or reject it — without forking LogTide.

Overview

Hooks are not a general event bus, an async emitter, or a plugin system. They are a fixed contract aimed at operators and downstream distributions that need to enforce policy or emit telemetry on the hot path.

Zero overhead by default

Nothing is registered by default. In an OSS deployment with no external modules, a hasHandlers guard short-circuits every phase, and the hook path is skipped entirely.

Phases

Before phases can observe, mutate, and reject. After phases are fire-and-forget observers — they cannot mutate or reject, and any error they throw is caught, logged, and discarded.

Phase When it runs
beforeIngestAfter auth, PII masking and quota checks, immediately before the reservoir write. Covers both HTTP (POST /api/v1/logs) and OTLP log ingestion.
beforeQueryAt the top of the main log query path, before the cache key is built. (Stats, histogram, trace and context endpoints are not covered in v1.)
beforeAlertEvaluationOnce per rule, inside the alert evaluation loop, before the rule is checked.
beforeWebhookDispatchImmediately before the outbound request, after headers and payload are assembled. Covers both the notification-channel and legacy alert-rule webhook_url paths.
afterIngestAfter the batch finishes, including paths where nothing was written. Carries counts only — no log content.
afterAlertTriggeredAfter an alert trigger is persisted to history, before notification jobs are enqueued.
afterWebhookDispatchAfter each webhook delivery attempt completes, on both the queued and synchronous paths.

Context Contract

Each phase receives a typed context object. Fields marked readonly must not be mutated; the remaining fields are the intended mutation surface.

BeforeIngestContext

export interface BeforeIngestContext {
  readonly organizationId: string | null;
  readonly projectId: string;
  /** Record count before hooks ran (snapshot; not recomputed after mutation). */
  readonly eventCount: number;
  /** Serialized-size estimate in bytes, computed only when handlers exist. */
  readonly byteSize: number;
  /** MUTABLE: filter/redact/modify entries, or assign a new array. */
  records: IngestLogRecord[];
}

Filtering or replacing records automatically realigns every downstream consumer (Sigma detection, exception parsing, pipeline processing, metering, correlation) so they never see phantom entries. Mutate fields in place or filter the array — do not clone records you want to keep, since downstream realignment is keyed by object identity.

Tenancy is enforced

Every record's projectId must still match the original project after hooks run. Changing it is treated as a cross-tenant write and fails closed with HTTP 500. Hooks cannot move data across tenant boundaries, even intentionally.

BeforeWebhookDispatchContext

export interface BeforeWebhookDispatchContext {
  readonly organizationId: string | null;
  readonly channelId?: string;
  readonly ruleId?: string;
  /** READONLY: mutating the target would sidestep validated, SSRF-checked config. */
  readonly url: string;
  readonly targetHost: string;
  /** MUTABLE: e.g. inject signing/compliance headers. */
  headers: Record<string, string>;
  /** MUTABLE: redact or enrich the payload before it leaves. */
  body: Record<string, unknown>;
}

The other before contexts follow the same shape: BeforeQueryContext exposes a mutable params (mutations drive both the cache key and the read), and BeforeAlertEvaluationContext is informational only (organizationId, ruleId, ruleType) — rejecting it skips that rule for the cycle. After-phase contexts are entirely readonly: counts, ids, and per-attempt delivery results for telemetry and audit logging.

Rejecting an Operation

A before-phase handler aborts the operation by throwing HookRejectionError(code, message, statusCode). The registry propagates it as-is, and the global error handler surfaces a machine-readable error body — the same path as capability and quota errors.

hooks.register('beforeIngest', async (ctx) => {
  if (ctx.byteSize > 5 * 1024 * 1024) {
    throw new HookRejectionError('policy.batch_too_large', 'Batch exceeds 5MB', 429);
  }
});
Phase Effect of a rejection
beforeIngest / beforeQueryThe operation aborts; the client receives the statusCode, code and message.
beforeAlertEvaluationThat rule is skipped for this cycle; the rest of the batch continues.
beforeWebhookDispatchThe delivery is recorded as failed; no retry.

Unexpected errors fail closed

Any thrown error that is not a HookRejectionError is wrapped in a 500. The operation is blocked, the original cause is chained into server logs only, and the client receives a bare Internal Server Error with no detail. After-phase handlers are the exception — their errors are logged and discarded, since the operation has already completed.

Registering Hooks

HOOKS_MODULES (container deployments)

Set HOOKS_MODULES to a comma-separated list of absolute paths to .js or .mjs files. Each must default-export a register function, called at boot on both the server and worker. A load failure is fatal — the process exits rather than run without the intended policy.

HOOKS_MODULES=/etc/logtide/hooks/policy.mjs,/etc/logtide/hooks/audit.mjs

External modules receive HookRejectionError via the second argument, so they can produce clean 4xx rejections without importing backend internals:

// /etc/logtide/hooks/policy.mjs
export default function register(hooks, { HookRejectionError }) {
  hooks.register('beforeIngest', async (ctx) => {
    // Reject batches over the policy limit with a clean 429
    if (ctx.byteSize > 5 * 1024 * 1024) {
      throw new HookRejectionError('policy.batch_too_large', 'Batch exceeds 5MB policy', 429);
    }
    // Mutate: strip a sensitive field before the reservoir write
    for (const record of ctx.records) {
      if (record.metadata) delete record.metadata.internal_token;
    }
  });

  hooks.register('afterWebhookDispatch', async (ctx) => {
    // Observe: emit a delivery metric
    metrics.increment('webhook.delivery', { success: String(ctx.success) });
  });
}

Code-level registration (downstream distributions)

A distribution that builds from source can import and call hooks.register() in its bootstrap, before the server starts accepting requests:

import { hooks, HookRejectionError } from 'packages/backend/src/hooks/index.js';

hooks.register('beforeIngest', async (ctx) => {
  if (ctx.byteSize > 5 * 1024 * 1024) {
    throw new HookRejectionError('policy.batch_too_large', 'Batch exceeds 5MB', 429);
  }
});

Conventions & Limits

No unbounded I/O. Hooks run on the hot path and there is no enforced timeout in v1. Handlers must not perform unbounded network calls or uncached database reads. Keep them fast and add your own timeout wrapper if you call an external service.
Sequential execution. Handlers for a phase run in registration order; each awaits the previous, so a later handler sees earlier mutations. The first handler to throw short-circuits the phase.
Request context. Handlers can read the current request context via context.currentOrNull() from @logtide/shared/context for organizationId, requestId, ip, userAgent and the authenticated actor.
v1 scope. beforeQuery covers only the main log query path (and fires once per live-tail poll tick — keep it cheap). There is no hook for OTLP trace or metric ingestion, and no afterQuery phase. eventCount and byteSize are pre-hook snapshots; use ctx.records.length for the post-mutation count.

The beforeWebhookDispatch and afterWebhookDispatch phases hook directly into the outbound dispatcher — see the Webhooks guide for the delivery contract and event envelope.

Esc

Type to search across all documentation pages