Logging and auditability
Record what your workflow did, why it did it, and the evidence it returned—with native Effect logs.
Laufwerk captures Effect logs by default. Inside an Activity.make body, use Effect.logInfo and Effect.annotateLogs. The runtime retains each message with its run, activity execution, attempt and tracing context. Open the activity in Studio to inspect its logs.
You do not need a Laufwerk logger, a tracing decorator or a logging service parameter.
Record a deterministic task
This task reads a policy file and records its SHA-256 digest. Reading the file is a side effect: placing it inside an activity preserves the returned digest when the workflow reuses that activity's stored result. The digest identifies the bytes inspected; it does not prove the policy was correct.
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { Activity } from "@effect/workflow";
import { Effect, Schema } from "effect";
export const readPolicy = (path: string, policyId: string) =>
Activity.make({
name: `read-policy/${policyId}`,
success: Schema.Struct({ digest: Schema.String }),
error: Schema.String,
execute: Effect.gen(function* () {
yield* Effect.logInfo("Reading approval policy");
const bytes = yield* Effect.tryPromise({
try: (signal) => readFile(path, { signal }),
catch: () => "Policy could not be read",
}).pipe(
Effect.tap(() => Effect.logInfo("Policy bytes read")),
Effect.withSpan("policy.read"),
);
return yield* Effect.gen(function* () {
const digest = createHash("sha256").update(bytes).digest("hex");
yield* Effect.logInfo("Policy fingerprint recorded").pipe(
Effect.annotateLogs({ digest, bytes: bytes.length }),
);
return { digest };
}).pipe(Effect.withSpan("policy.fingerprint"));
}).pipe(
Effect.tapError((reason) =>
Effect.logError("Policy inspection failed").pipe(
Effect.annotateLogs({ reason }),
),
),
Effect.annotateLogs({ policyId }),
),
});Call it inside your registered workflow:
const policy = yield* readPolicy(policyPath, "invoice-approval-v3");Keep activity names stable and unique within the workflow execution. If you need two distinct reads, give them distinct durable names. Reusing the same name requests the stored result; it does not refresh the file.
Choose useful messages
| Level | Use it for | Default capture |
|---|---|---|
Effect.logInfo / Effect.log | Progress, decisions, policy versions, counts and external receipts | Yes |
Effect.logWarning | Recoverable problems, fallback choices and incomplete evidence | Yes |
Effect.logError | Failures or external outcomes that remain unconfirmed | Yes |
Effect.logFatal | An unrecoverable condition identified by your code | Yes |
Effect.logDebug | Diagnostic details useful during investigation | Opt in |
Effect.logTrace | Very detailed execution diagnostics | Opt in |
Logging an error does not fail the task. Use Effect.fail or preserve the original failure with Effect.tapError / Effect.tapErrorCause. To include an Effect cause, use Effect.logError("Verification failed", cause) inside tapErrorCause.
Use structured fields for business context:
const recordDecision = Effect.logInfo("Invoice approved").pipe(
Effect.annotateLogs({
invoiceId: "INV-42",
policyVersion: "invoice-approval-v3",
receiptId: "approval-123",
}),
);Only say “approved” after you have authoritative confirmation. A timeout after submitting an approval means the outcome may be unknown. Record the idempotency key and reconcile with the external system.
audit: true can be your own annotation convention, but it has no special filtering, retention or security behavior. Required evidence should be emitted at INFO or above, with configuration that retains it.
Spans and execution context
Activity.make supplies an activity tracing span. Use Effect.withSpan("step-name") for meaningful substeps. A log records the current span and its ancestors, including trace IDs, span IDs and parent relationships. Studio's span filter includes a span's descendants.
This first version retains spans referenced by logs, plus the activity lifecycle. A nested span that emits no logs has no separate duration view in Studio. Effect.withLogSpan is different: it adds elapsed-time fields to messages. It is optional and is not required for tracing correlation.
Laufwerk keeps runtime identity separate from author annotations:
- Run ID and operation ID identify the retained run and activity.
- Workflow name and Effect execution ID identify the workflow execution.
- Activity name, attempt and activity execution ID identify an actual invocation of its body.
- Span ancestry identifies where the message was emitted, including nested spans and child fibers.
- Timestamp, level, fiber ID, message, annotations and an optional cause describe the log itself.
An annotation named runId cannot overwrite the event's runtime run ID. Actor identities in annotations are still application-supplied claims: derive them from your authenticated context.
Logs outside activities appear under Workflow logs. Workflow code can rerun on resume, so these messages may repeat. Put evidence about a side effect inside its activity body.
Keep your own loggers
Use native Effect layers to add a logger without replacing Laufwerk's capture:
import { Effect, Logger, LogLevel } from "effect";
const custom = Logger.make((entry) => {
const record = Logger.structuredLogger.log(entry);
// Send the record to your own synchronous sink or managed queue.
}).pipe(Logger.withSpanAnnotations);
// `program` is your existing workflow implementation Effect.
const instrumented = program.pipe(
Effect.provide(Logger.add(custom)),
Logger.withMinimumLogLevel(LogLevel.Debug),
);Use instrumented as the implementation returned by your workflow's toLayer callback. The custom logger receives messages alongside Laufwerk's retained logs and stderr diagnostics. Scoping the minimum level to a task also works. Effect filters below-minimum messages before loggers receive them; raising the minimum to WARN excludes INFO from every sink in that scope.
Logger callbacks are synchronous. Returning a Promise from a callback does not make Effect wait for remote delivery. Manage your export queue and shutdown/flush behavior explicitly. You can also provide your own Effect tracer/exporter; Laufwerk does not replace the tracer.
See Effect's logging and tracing documentation for the underlying APIs.
Inspect logs in Studio
Open a run and select an activity in Observed execution. Its Logs panel shows chronological messages, levels, timestamps and the emitting span. Filter by level or span, then expand a message to inspect its fields, cause and trace/execution identifiers.
Studio renders at most 50 log rows at a time. New logs update the count and show a Show latest action without moving the page you are reading. Earlier records remain available through pagination. The existing run detail API and event stream carry these records as effect.log events.
Understand the audit boundary
- Persistence: Laufwerk writes each captured message synchronously to its runtime SQLite event store. A storage failure surfaces as an execution defect; capture does not silently acknowledge success. This does not make the log write atomic with an external business transaction.
- Replay: returning a stored activity result creates no new activity-body logs or lifecycle events. Real re-execution creates a new activity execution ID; repeated identical messages remain separate records.
Activity.retrychanges the attempt. Internal interruption retries can occur within one invocation, so the attempt is not a universal count of external requests. - Failures: a worker killed before it records completion leaves an unmatched start. A “requested” log is evidence of intent, not proof of an external commit. Use idempotent external operations, durable receipts and reconciliation.
- Sensitive data: log identifiers and summaries instead of credentials, prompts or entire payloads. Effect
Redactedvalues keep their redacted representation. Plain strings are not automatically scrubbed for secrets. - Capture limits: strings are limited to 8,000 characters, collections to 100 entries, nesting to eight levels, and message/annotation/cause text shares a 32,000-character budget. Truncated records are marked in Studio. Cycles, big integers and unserializable values receive safe representations. Keep complete artifacts in appropriate storage and log their identifiers/digests.
- Retention: these logs follow existing run retention. Explicit runtime pruning deletes them with their run. The local database is not tamper-proof storage, a legal archive, or a guaranteed remote export. Apply your organization's retention and access requirements separately.
The default is a useful, attributable execution history. Stronger business audit guarantees depend on the systems performing and retaining the business transaction.