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 |
|---|---|
beforeIngest | After auth, PII masking and quota checks, immediately before the reservoir write. Covers both HTTP (POST /api/v1/logs) and OTLP log ingestion. |
beforeQuery | At 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.) |
beforeAlertEvaluation | Once per rule, inside the alert evaluation loop, before the rule is checked. |
beforeWebhookDispatch | Immediately before the outbound request, after headers and payload are assembled. Covers both the notification-channel and legacy alert-rule webhook_url paths. |
afterIngest | After the batch finishes, including paths where nothing was written. Carries counts only — no log content. |
afterAlertTriggered | After an alert trigger is persisted to history, before notification jobs are enqueued. |
afterWebhookDispatch | After 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 / beforeQuery | The operation aborts; the client receives the statusCode, code and message. |
beforeAlertEvaluation | That rule is skipped for this cycle; the rest of the batch continues. |
beforeWebhookDispatch | The 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
context.currentOrNull() from @logtide/shared/context
for organizationId, requestId, ip, userAgent
and the authenticated actor.
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.