Logging

Configure the SDK's log writers, record user interactions, write your own messages and export the retained logs as files.

Preview

This page describes a pre-release version of the SDK. Names, versions and APIs on this page can change before the release.

The SDK writes one log journal per runtime. It holds the actions your app sends to the stores, the work they start and its results, the interactions your app records and the messages your app writes. Every entry carries the Folio session it belongs to, so a support case can be matched to the server side. You choose where the journal goes: a file on the device, the platform console, or both.

Configure the writers

FolioSdkConfig.logging is a LoggingConfig with a list of writers, at most one of each kind:

WriterSwiftKotlinTypeScriptWrites to
File.file(level:policy:)LogWriterConfig.File(level, policy)LogWriterConfig.file(level, policy)Files in the app's documents directory under folio/logs; on the web, the origin private file system
Console.console(level:)LogWriterConfig.Console(level)LogWriterConfig.console(level)The iOS unified log, Android logcat or the browser console

Each writer takes the entries of its level and above. The levels, from the lowest, are TRACE, DEBUG, INFO, WARN and ERROR (.trace to .error in Swift, LogLevel.TRACE to LogLevel.ERROR in Kotlin and TypeScript). Both writers work in debug and release builds of your app.

FolioSdk.loggingConfig(consoleLevel) builds the usual configuration: a file writer at TRACE with the default limits and, when you pass a level, a console writer at that level. Pass no level (nil, null or undefined) to write the file only. A LoggingConfig with no writers turns logging off.

Swift
let config = FolioSdkConfig(
    baseUrl: "https://app.folio.mobi",
    organization: "<organization>",
    development: nil,
    logging: try FolioSdk.loggingConfig(consoleLevel: .debug)
)

The file writer keeps its files within the limits of its JournalPolicy:

FieldDefaultAllowed
segmentBytes4 MiB per file1 KiB to 16 MiB
totalBytes64 MiB for all filesAt least segmentBytes, at most 256 MiB
retentionMillisSeven daysAbove zero
eventBytes64 KiB per entry512 bytes to 64 KiB, at most segmentBytes

When the files reach totalBytes, or a file is older than retentionMillis, the SDK deletes the oldest complete files. An entry larger than eventBytes is rejected.

TypeScript
const logging: LoggingConfig = {
    writers: [
        LogWriterConfig.file(LogLevel.TRACE, {
            segmentBytes: 4194304n,
            totalBytes: 67108864n,
            retentionMillis: 604800000n,
            eventBytes: 65536n,
        }),
        LogWriterConfig.console(LogLevel.DEBUG),
    ],
};

A policy outside these limits, or a kind of writer listed twice, makes FolioSdk.create fail with the CoreError LogsUnavailable. A writer that cannot open, for example because the file system is not available, does not stop the runtime: the other writer keeps working, and the failure shows in status() of an SdkLogger.

What the journal contains

  • Session. Each entry carries a LogContext: runId, an identifier of this runtime, session, the Folio session (SessionContext Server with its id, or Unbound before the session starts), interactionSeq, set when the entry belongs to a recorded interaction, and engineId, the store engine that handled the action.
  • Stores. The actions your app dispatches, their effects and results, and the shape of each new state. Credentials, typed values and binary data are left out.
  • Requests. The HTTP requests of the SDK, as messages of the target http: Request started, then Request completed with http_status or Request failed with failure. Each carries method, http_service and http_path, and the last two also duration_millis. http_service is the Folio service the URL names after /api/, with its environment suffix, such as um or vault-fix-um, or the host of a URL outside /api/. It is absent when that segment is empty, is longer than 64 characters or holds anything but lowercase letters, digits and hyphens; http_path then still starts after that segment. http_path is the rest of the path without the query, with every segment that is neither a version such as v1 nor a word of at most 32 lowercase letters and hyphens replaced by {id}, for example /v1/sessions/{id}. These messages hold no full URL, query, header or body.
  • Platform attestation. When a session starts, the SDK asks Folio to verify the app and the device: with Android Key Attestation on Android and App Attest on iOS; the other platforms report attestation as unsupported. When Folio refuses a request of this exchange, the warning Platform attestation request failed of the target runtime carries attestation_step (REQUEST_CHALLENGE, APPROVE_ANDROID, APPROVE_IOS or SKIP), http_status, error_code and failure. An app whose Android package and signing certificate, or iOS team and bundle id, Folio does not know gets error.um.platform_attestation_invalid; after it the SDK sends no attestation for 24 hours, or until the realm or the version of the app, the SDK or the system changes. The session works either way. When the device cannot produce the evidence, the warning Platform attestation evidence unavailable carries failure.
  • Runtime. The start of the runtime with its build info, session changes, and a last entry RUNTIME_STOPPED when the runtime shuts down. That entry marks the end of the runtime on the device, not the end of the Folio session.
  • Your entries. The interactions you record and the messages you write, as described below.

The text of an interaction's identifiers and of your messages is yours: write stable labels, never what the person typed or other personal data.

Record interactions

InteractionRecorder.mount(runtime) returns a recorder. Record each user interaction with an InteractionEvent and pass the context it returns to the action the interaction causes. The journal then ties the interaction, the action and its result together.

Swift
let interactions = try InteractionRecorder.mount(runtime: sdk)
switch try interactions.record(event: .tap(screen: "sign-in", target: "submit")) {
case let .accepted(context):
    try auth.dispatch(action: .answer(value: answer), context: context)
case let .rejected(context, error):
    report(error)
    try auth.dispatch(action: .answer(value: answer), context: context)
}

Every store takes an optional second argument of dispatch, a LogContext. Without it, the entry gets the current session and no interaction. The SDK sets engineId itself and ignores the value in your context. A context whose runId belongs to another runtime makes dispatch throw the CoreError InvalidInput. record does not throw when the journal cannot take the event: it returns Rejected with the context and a LogError; report the error and go on with the interaction.

InteractionEvent has these cases, each with the screen it happened on:

CaseFields
Tapscreen, target
LongPressscreen, target
Submitscreen, target
Focusscreen, target
Blurscreen, target
Togglescreen, target, enabled
ChangeFieldscreen, field, populated
SelectTabscreen, tab
Scrollscreen, target, offset
KeyPressscreen, target, key (InteractionKey)
Swipescreen, target, direction (SwipeDirection)
Navigatefrom, to

ChangeField records only whether the field holds a value, never the value. InteractionKey is ENTER, ESCAPE, TAB, ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, BACKSPACE, DELETE or TEXT_INPUT; SwipeDirection is UP, DOWN, LEFT or RIGHT.

Write your own messages

SdkLogger.mount(runtime, target) returns a logger whose entries carry target, for example the name of your module. It has trace, debug, info, warn and error, each with the message text, an optional error and an optional LogContext, and message(level, text, error, context) for a level chosen at run time. Each call returns the sequence number of the entry. exception(level, text, error, context) takes an error that is always present.

The error is the platform's own error value: any Error in Swift, a Throwable in Kotlin and any thrown value in TypeScript. The SDK writes its type, name, message, stack and causes into the entry; Swift errors have no stack. In Swift, the name is String(reflecting:) of the error's type followed in brackets by the domain and code the error has as an NSError, and the message is its localizedDescription. Swift gives an error type of your own the same qualified type name as its domain. Its code is 1 for a struct or class, and the position of the case for an enum, where the cases with associated values are counted first, in declaration order, and then the cases without. So the first case of an enum ExampleFailure whose cases have no associated values, in the module FolioSDKExample is recorded as FolioSDKExample.ExampleFailure [FolioSDKExample.ExampleFailure:0], and a struct ExampleFailure as FolioSDKExample.ExampleFailure [FolioSDKExample.ExampleFailure:1]; an NSError keeps its own domain and code. In TypeScript, undefined and null mean no error for the other calls; in a catch block use exception, which records every thrown value, undefined included.

TypeScript
const logger = SdkLogger.mount(runtime, 'checkout');
try {
    await navigator.clipboard.writeText(text);
} catch (error: unknown) {
    logger.exception(LogLevel.WARN, 'Copy failed', error, undefined);
}

When the journal cannot take an entry, the call throws the platform's SDK error with the text of the LogError. status() returns a LoggingStatus: runId, acceptedSeq, the number of rejectedEvents, the error of the last rejected entry, closed, and one LogWriterStatus per writer with its writerType, writtenSeq, failedEvents and last error. flush() waits until every accepted entry is written.

Close a recorder and a logger like a store when you no longer need them, and before you close the runtime: release() in Swift, close() in Kotlin and dispose() in TypeScript.

Read and export the logs

LogsStore reads the journal that the file writer keeps. Mount it with a LogFilter: All, Session with a Folio session id, or Run with a runId.

ActionResult in the state
Load(filter, offset)Page with the status and a LogPage: up to 50 entries, each a LogLine, and the nextOffset to load the next page, if any.
Export(filter)Export with the status and a LogExport: its id and the files, each with name and size in bytes.
ReadExport(exportId, file, offset)ExportChunk with up to 64 KiB of the file from offset.

Each LogLine of a page is one journal entry:

FieldContent
seqThe sequence number of the entry within its run.
atWhen the entry was written, in milliseconds since the Unix epoch.
levelThe LogLevel of the entry. Interactions, session changes and the runtime stop are INFO.
targetFor a message, the target of its writer: the target you passed to SdkLogger.mount for your own messages, and for the SDK's messages the Rust module path of the code that wrote it, such as folio_sdk_auth::dpop_custody, or a named target, such as http for the request messages, runtime for the session start and platform attestation, and store.input and store.loop for the store runtime. For the other entries a fixed target: store for actions, effects, component events and store warnings, app.runtime for the runtime start and Folio session events, interaction for a recorded interaction, session for a session change and runtime for the runtime stop.
textThe readable entry, the same text a .log file of an export holds after the time and the context.
jsonThe full journal entry with its LogContext, the same line a .jsonl file of an export holds.

Mounting the store loads the first page of its filter. The state starts as Loading, every action sets it to Loading again, and only the result of the latest action is applied. A failed action gives Failed with the status and a LogError. An export holds the matching entries as JSON lines, the same entries as readable .log files, and a manifest.json that names the filter, the retention limits, the exported range and the writer errors. Read each file chunk by chunk until you have its bytes, then save or share them with your platform's file API. A new export replaces the previous one. An export covers what the files still retain: entries the limits have already removed are gone.

In React, useLogsStore(init) from @folio/sdk/stores mounts the store with the LogFilter init; on Android, rememberLogsStore(init) does.

Errors

LogError is the error of the journal. It reaches you in a Rejected interaction, in a thrown logger call, in LogsUiModel.Failed and in the error fields of LoggingStatus.

Failures of the interfaces your app implements go to the bridge error handler, not to this journal; set the handler to write them to your logger, see Bridge failures.

CaseFieldsMeaning
InvalidConfigmessageThe logging configuration is not valid.
EventTooLargelimitThe entry is larger than eventBytes.
QueueFull128 entries wait to be written; the entry was dropped. The next entry can succeed.
BusyThe journal was locked for too long. The writer tries again with the next entry.
ClosedThe runtime has shut down.
ForeignContextThe LogContext comes from another runtime.
ArchiveUnavailableLogsStore needs the file writer, and the configuration has none or it did not open; or ReadExport names an export other than the latest.
IomessageReading or writing the files failed. The writer stops; the other writer keeps working.
CorruptmessageA file holds an entry that cannot be read. Loading and exporting fail instead of skipping it.
SerializationmessageAn entry could not be encoded.

Rejected entries are counted in rejectedEvents and in the export manifest. When the logging of the runtime cannot start or cannot finish, FolioSdk.create, shutdown() and close() fail with the CoreError LogsUnavailable; see Errors.

Shutdown

shutdown() and close() of the runtime write every accepted entry and the RUNTIME_STOPPED entry before they return. Entries your app sends after that are rejected with Closed.

On this page