Skip to main content

Logging

Available as context.logger. Four levels (info, warn, error, debug). Each takes a message string plus optional structured fields.

context.logger.info("Fetched 42 invoices from ERP")
context.logger.warn("Skipping malformed invoice INV-007")
context.logger.error("ERP returned 500", detail={'request_id': 'r-123'})
context.logger.debug("Raw payload", detail=payload)

Where log lines go​

In productionIn the toolkit
Stderr is captured by the worker, stored against the job's log_token, and surfaced in the admin portal log viewer.Stderr prints to your terminal. That's it.

Each call may also POST a structured log entry to /v1/job/log for the structured-log viewer. The framework turns this on per run (Logger.attach_api()); in the toolkit, "forward_structured_logs": true in the settings file does the same. Off by default. Stderr is the durable record either way.

Format​

Each call produces one stderr line:

2026-05-16 14:32:11 UTC [INFO] Fetched 42 invoices from ERP

Format is fixed. Don't try to override it. If you need machine-readable output, return a structured dict from run(). That's the connector's contract for emitting "the result".

The detail and step keyword args​

Both are optional. They're only consumed by the structured-log forwarder; the plain stderr line just gets the message text.

context.logger.info("Posting batch", step='upload', detail={'count': 50})
context.logger.error("SFTP login failed", step='sftp_connect',
detail={'host': sftp_host, 'attempt': 3})

Use step to tag where in the entrypoint a line came from: 'sftp_connect', 'fetch_external', 'save_to_nuntiq'. The structured log viewer groups by step when it's set, which is much friendlier than reading the raw text in the post-mortem.

Use detail for anything you'd want to filter or pivot on: request ids, record counts, error codes, retry attempts.

What NOT to do​

Don't rely on print()​

The framework redirects stdout while your code runs, so print() output lands in the job log as plain text and does not corrupt the result line. It has no level, no timestamp and no step or detail, so use the logger for anything you want to find later.

print("hello") # lands in the log, unformatted
context.logger.info("hello") # ✅ timestamped, levelled, structured

Don't log secrets​

detail is not redacted. Treat the structured log viewer like any other observability surface: anyone with access to the log can see what you logged.

context.logger.info("Posting", detail={'token': api_token}) # ❌ secret leak
context.logger.info("Posting", detail={'auth': 'bearer ***'}) # ✅

Don't log INFO once per record​

A 50k-row sync that logs every record produces 50k log lines. Log storage is bounded; downstream alerting can't cope; the post-mortem is unreadable.

# ❌ one line per record
for r in rows:
context.logger.info(f"Saving {r['id']}")
save(r)

# ✅ count at start and end; log on failure
context.logger.info(f"Saving {len(rows)} rows", step='save')
for r in rows:
try:
save(r)
except Exception as e:
context.logger.warn(f"Failed {r['id']}: {e}", step='save')
context.logger.info(f"Save complete: {n_ok} ok, {n_fail} failed", step='save')

Common patterns​

Start/end markers​

def run(context):
context.logger.info(f"Starting {context.entrypoint_key}", step='start')
...
context.logger.info(f"Done: processed {count} records", step='end',
detail={'count': count, 'failed': failed})

One line per external request that took unusual time​

import time
t0 = time.monotonic()
resp = requests.get(url, timeout=30)
dt = time.monotonic() - t0
if dt > 5:
context.logger.warn(f"Slow external call ({dt:.1f}s)", detail={'url': url})

Wrap exceptions with context​

try:
sftp.put(local_path, remote_path)
except paramiko.SSHException as e:
context.logger.error(f"SFTP put failed: {e}",
step='sftp_put',
detail={'remote': remote_path, 'local_size': os.path.getsize(local_path)})
raise # re-raise so the framework marks the job failed

What's next​

  • Work dir: what to write where, and what survives.
  • Architecture: what happens to your stderr after the container exits.