For the complete documentation index, see llms.txt. This page is also available as Markdown.

Logging

Frigg writes one redacted JSON record per line to stdout. Every record has the same field set and the correlation ids of ADR-011. The decision is ADR-048. This guide tells you how to write, read and test logs.

For integration and API module code, start with Logging in an Integration.

Quick start

In an integration or an API module, use this.logger. It already has its name and its ids:

async processContactBatch({ data }) {
    this.logger.info('Contact batch started', {
        eventName: 'integration.hubspot.batch_started',
        batchSize: data.contacts.length,
    });
}

In core, get a named logger one time per module:

const { getLogger } = require('../logs');
const log = getLogger('frigg.core.sync');

log.warn('Sync skipped', { eventName: 'frigg.core.sync.skipped', processId });
  • The message is first. Keep it fixed text, so a query can group it. Put ids and counts into fields.

  • A logger has trace, debug, info, warn, error, fatal(message, fields?), child(bindings) and isLevelEnabled(level).

  • child(bindings) adds fields to each record. Give a function to read the values per record: log.child(() => ({ processId: this.processId })).

  • The logger never throws. It never uses console.*.

The record

One record, shown on several lines (stdout has it on one line):

Field reference

Field
Type
Source
When present

timestamp

string

logger, RFC 3339 UTC with milliseconds

always

level

string

TRACE, DEBUG, INFO, WARN, ERROR, FATAL

always

message

string

call site; an Error becomes its sanitized message, other values become [non-string message] plus value

always

logger

string

getLogger(name): frigg.<area>, integration.<name>, module.<name>

always

appName

string

FRIGG_STACK

when set

stage

string

STAGE

when set

requestId

string

invocation scope, from context.awsRequestId

inside createHandler and the DLQ processor

handlerName

string

the eventName option of createHandler

inside createHandler

method, routeKey

string

the HTTP event

HTTP invocations

route

string

the route template, never the concrete path: REST v1 resource, or the path part of the HTTP API v2 routeKey (/api/integrations/{id}); the path only when the event has no template

HTTP invocations

invocation

object

the bounded event summary: { source, method, route, routeKey } for HTTP, { source, recordCount } for SQS, else { source }

inside an invocation scope

invocation.path, invocation.queryKeys, invocation.headerNames

string, string[], string[]

the full request summary: the concrete path after redactUrl (token-looking segments become [REDACTED:<len>]), the query keys and the header names, never values

only on frigg.handler.invoked (INFO), frigg.handler.failed and frigg.handler.halted (ERROR), and frigg.http.request_failed (ERROR)

messageId, receiveCount

string, number

message scope, from the SQS record

inside Worker.run and the DLQ processor

processId, integrationId, integrationEvent

string

message scope (the message body), or the integration context

when known

integrationType, userId, version

string

the ADR-011 integration context

when known

entityId, credentialId

string

Module bindings

in API module records

eventName

string

call site: the logger name, then .<action>

required for frigg.* at WARN and above, and for framework lifecycle INFO

trace_id, span_id, trace_flags

string (hex)

the active OTel span

inside a span only

error

object

{ type, message, code, status, stack, cause }, sanitized

when a call site passes error

droppedKeys

string[]

logger

when the logger removed a field (see Precedence)

Call-site fields go next to these, at the top level.

Rules the logger applies

  • Precedence. Required, resource and trace fields win, then scope fields, then child() bindings, then call-site fields. A key that loses goes to droppedKeys. So a call site cannot replace requestId or level.

  • Reserved keys. The logger never writes tenantId, type, time, record, errorType, errorMessage, stackTrace, service, env, host, source, status or severity at the top level. They go to droppedKeys. Use statusCode for an HTTP status. Nested keys such as error.status and invocation.source are fine.

  • Payloads. body, rawBody, payload and response are dropped at INFO and above. Log counts or keys instead.

  • Caps. A string is cut at 2,048 characters after redaction. Object depth is 6 ([Depth]), cause depth is 3. A record is at most 16 KB: the logger drops call-site fields largest first, then error.stack, then error.cause. It always writes the record.

  • Null. null and undefined fields are left out.

  • invocation detail. A call-site invocation object adds keys to the scope's invocation; a key the scope already has keeps the scope value (droppedKeys gets invocation.<key> when they differ).

Nested scopes merge

telemetry.withContext(ids, fn) now merges into the outer scope instead of replacing it. An inner undefined id keeps the outer value; an explicit null clears it (stored as null, left out of log records).

Redaction

Redaction runs inside the logger for every record. Call sites carry no redaction duty, but redaction is a backstop, not a permission: do not log a token and trust the logger.

  • Keys that end in token, secret, password, apikey, privatekey, signature, authorization or cookie (case, - and _ ignored), plus hashword and the encryption registry fields, get [REDACTED].

  • A headers object becomes its names. URLs lose userinfo, and each query value becomes REDACTED.

  • Strings are scrubbed: Bearer and Basic credentials, JWTs, credentialed connection strings, client_secret=/code=/signature= pairs, long base64 and hex runs become [REDACTED:<len>]. A key that ends in sha256, hash, digest or checksum keeps its hex value.

  • An Error at any depth goes through the error serializer.

The rules cover credentials, not personal data. Use DEBUG in production only for a limited time.

Stability rule

The record contract is public. Queries, subscription filters and sinks read it. A release can add a field. A rename or removal of a field needs a core major version. Records carry no schema version: the core version that the app deploys defines the schema.

Levels and FRIGG_LOG_LEVEL

Level
Meaning
Frigg examples

TRACE

Step detail. Off by default.

Requester backoff

DEBUG

Diagnostics, redacted payloads.

OAuth callback steps

INFO

Lifecycle facts.

Handler entry; status changed

WARN

Handled. The system continues.

SQS retry; inbound 4xx; config fallback

ERROR

The operation failed.

Unhandled 5xx; halt; DLQ

FATAL

The process cannot continue.

Invalid config at INIT

Log an error one time, at the boundary that decides its outcome. Inner layers throw with cause and do not log. createHandler, the express error middleware, Worker.run and the DLQ processor are the boundaries.

FRIGG_LOG_LEVEL sets the minimum level. The logger drops a record below it before it reads any field.

  • Default INFO. A local run (STAGE=local, or IS_OFFLINE = true or 1) defaults to DEBUG.

  • Case and spaces do not matter. An empty value counts as unset. An unknown value means INFO, and the logger writes one frigg.logger.invalid_level WARN.

  • When AWS_LAMBDA_LOG_LEVEL is also set, the less verbose level wins, with one frigg.logger.level_conflict WARN when they differ.

  • DEBUG_VERBOSE=1 means DEBUG while FRIGG_LOG_LEVEL is unset (legacy).

  • The logger reads the level one time, at the first record, and never from SSM.

Build expensive detail only when it is written:

Reading logs

Locally with jq

fromjson? skips lines that are not JSON (CLI output, legacy console.* lines):

osls offline runs handlers in worker threads, so the JSON lines of several workers can reach the pipe out of order. Use --useInProcess when you pipe to jq.

CloudWatch Logs Insights

The fields need no parse step:

Subscription-filter patterns

JSON filter patterns match the record fields directly:

Destinations

stdout is the source of truth. On AWS, ship logs out of process first. That keeps the send, its credential and its failures out of the invocation:

  1. Lambda log delivery to Amazon Data Firehose (then to S3 or a vendor).

  2. A CloudWatch Logs subscription filter (patterns above) to a Lambda, Firehose or Kinesis.

  3. A vendor Lambda extension.

  4. An OpenTelemetry collector layer.

An in-process destination sink (ADR-048 §11) is not public yet. The internal sink interface exists (the stdout and memory sinks use it), and runInvocationScope already flushes sinks against the invocation deadline. These rules wait for the first real destination, and ship with it:

  • the destination contract { name, minLevel, translate(record), send(batch, { signal }) };

  • appDefinition.logging.sinks registration and its schema entry;

  • one buffer per sink, at most 1,000 records or 1 MB, with drop counts;

  • one flush per sink at a time; abort and drop at the deadline, no retry in a later invocation;

  • the fixed stderr lines frigg.logger.sink_failed and frigg.logger.sink_disabled, never with the error message or the URL;

  • disable a sink after 3 failed flushes in a row;

  • raise a minLevel that is less strict than the effective level, with one warning.

An in-process sink in a VPC needs an egress path.

Deployment: the logging block

  • No block, no change. Without logging, devtools emits no LoggingConfig, no retention and no FRIGG_LOG_LEVEL, and frameworkVersion stays as it is.

  • level (trace … fatal, all lower or all upper case):

    • A level other than info sets the literal FRIGG_LOG_LEVEL=<LEVEL> on every function. It wins over environment: { FRIGG_LOG_LEVEL: true }.

    • Under frigg start, logging.level applies too, info included (a local run without a level defaults to DEBUG). A FRIGG_LOG_LEVEL in the shell or in .env wins over it, for the local run only. Deploys never read the shell value.

    • An unknown level fails the build.

    • Lambda stays on its default Text log format for now. The Lambda JSON LoggingConfig (provider.logs.lambda, frameworkVersion >=3.58.0) waits for the ADR-048 Phase 0 checks; FRIGG_LOG_LEVEL filters in process until then. When it is on, do not set a function-level logs block: it replaces the provider block, it does not merge with it.

  • retentionInDays sets provider.logRetentionInDays for every Lambda log group. Allowed values are the CloudWatch list (1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653). Any other value fails the build before CloudFormation starts. The deployment IAM policy grants logs:DeleteRetentionPolicy, so you can remove the setting later.

  • format accepts only json.

  • SSM offload: FRIGG_LOG_LEVEL, AWS_LAMBDA_LOG_LEVEL, AWS_LAMBDA_LOG_FORMAT, OTEL_FLUSH_TIMEOUT_MS, DEBUG_VERBOSE and PRISMA_LOG_LEVEL are read at INIT, so they never move to SSM.

  • frigg init writes logging: { level: 'info', retentionInDays: 30 } for new apps.

Testing

jest-logger-setup.js (registered in packages/core/jest.config.js) installs a memory sink before each test, sets the level to TRACE, and fails a test that wrote a frigg.* WARN or above with no eventName. No JSON reaches the test output.

Assert on records, not on console spies:

  • createMemorySink() installs itself as the only sink. createMemorySink({ install: false }) gives a sink to pass to resetLoggerForTests({ level, sinks }).

  • resetLoggerForTests() re-reads the env on the next record. Pass sinks, or it installs the stdout sink.

  • Records in the sink are deep-frozen, and JSON.stringify(record) equals the stdout line.

  • To prove that no secret leaks, core tests use expect(sink.records).toContainNoSecretWindow(secrets) (registered by the setup file; the fixtures are in packages/core/logs/__fixtures__/). Use fake secrets of 16 characters or more.

A test outside packages/core can add the same setup file: setupFilesAfterEnv: ['@friggframework/core/logs/jest-logger-setup.js'].

Migrating from debug, initDebugLog, flushDebugLog

The three functions are deprecated shims for one major version. They write records on the frigg.legacy logger and keep no module state:

Old
Now
Use instead

debug(...args)

one DEBUG record; the first string is the message (util.format when it has a % directive), the rest goes to args

this.logger.debug(message, fields)

initDebugLog(eventName, event)

nothing inside an invocation scope; else one DEBUG record with the redacted invocation

nothing: createHandler opens the scope

flushDebugLog(error)

one ERROR record, eventName: 'frigg.legacy.error', no replay of debug lines

log one time at the boundary, or throw with cause

Records below the effective level are not kept for a later flush. Set FRIGG_LOG_LEVEL=debug to see debug records.

Migrating a call site

Adoption is incremental: new code uses the logger, and existing console.* calls move when someone touches them. Until then a log group has JSON and text lines, and the text lines have no correlation ids and skip redaction.

  1. Get the logger: this.logger in an integration or an API module, getLogger('frigg.<area>') in core (one per module, at module scope).

  2. Pick the level from the table above, not from the old console method.

  3. Make the message fixed text. Move ids, counts and names into fields.

  4. Give each frigg.* record at WARN and above, and each lifecycle INFO, an eventName: the logger name, then .<action>.

  5. Pass an error as { error }. Do not log and rethrow; the boundary logs.

  6. Do not log bodies, headers or tokens at INFO. Log keys or lengths (dataKeys, bodyLength).

  7. In the test, replace the console spy with a memory sink and assert by eventName. Keep expect(consoleSpy).not.toHaveBeenCalled() so the old call cannot come back.

Last updated

Was this helpful?