app_factory_logging

Privacy-safe logging for Flutter apps, with legacy AppLogger calls and the structured app.runtime-log.v1 event contract.

The package provides centralized redaction and bounds before data reaches the console, test fake, or sink. It stays separate from analytics and crash reporting and ships no local persistence or remote log transport.

Features

  • Five levels: trace / debug / info / warn / error
  • Existing logger.info('repository', 'load completed') calls remain valid
  • app.runtime-log.v1 events with stable machine-readable eventName values
  • Central sanitization for messages, errors, stack traces, attributes, and URLs
  • Bounded JSON-safe attributes and whole-event size limits
  • Single-line APP_RUNTIME_LOG {json} console framing for structured events
  • ConsoleAppLogger, sanitized FakeAppLogger, and Riverpod providers
  • AppLogEventSink for structured capture and legacy AppLogSink breadcrumbs
  • SanitizingAppLogger for existing custom or third-party logger backends
  • Provider Observer that records lifecycle metadata without provider values
  • Full-message sanitization before bounded debugChunked splitting

Installation

dependencies:
  app_factory_logging: ^0.2.0
flutter pub get

Bootstrap

Create one logger and expose it through Riverpod. Console output defaults to debug builds; release builds stay silent unless a sink is explicitly supplied.

import 'package:app_factory_logging/app_factory_logging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  final logger = ConsoleAppLogger(
    // sink: MyBreadcrumbSink(),       // warn/error only
    // eventSink: MyBoundedEventSink(), // every allowed level
  );

  final container = ProviderContainer(
    overrides: [appLoggerProvider.overrideWithValue(logger)],
    observers: [
      if (kDebugMode)
        AppLoggerProviderObserver(
          logger,
          include: {
            'authControllerProvider',
            'bootstrapControllerProvider',
          },
        ),
    ],
  );

  runApp(
    UncontrolledProviderScope(
      container: container,
      child: const MyApp(),
    ),
  );
}

appLogSinkProvider and appLogEventSinkProvider both default to null and may instead be overridden during bootstrap.

Migrating from 0.1.x

Existing AppLogger calls and the AppLogSink.write method signature remain source-compatible. appLogEventSinkProvider and logger.event(...) are new optional APIs; custom AppLogger implementations continue to receive logger.event(...) as the equivalent legacy-level call unless they implement StructuredAppLogger.

0.2.0 changes output behavior: built-in fakes and AppLogSink receive sanitized error and stack text rather than the original object identities, and debugChunked sanitizes then caps output at 16 chunks by default. The existing ConsoleAppLogger.maxMessageLength parameter is now validated against the published Schema range of 0–4096 characters. No Dart/Flutter/Riverpod constraint change is required from 0.1.1.

Legacy calls

Existing business calls keep their source shape:

final logger = ref.read(appLoggerProvider);

logger.trace('rtc', 'socket state=connecting');
logger.info('repository', 'load completed');
logger.error(
  'repository',
  'load failed',
  error: error,
  stackTrace: stackTrace,
);

Bind a class-derived diagnostic tag once when stable cross-release querying is not required:

final class AsrClient {
  AsrClient(AppLogger logger) {
    _log = logger.forOwner(this);
  }

  late final AppLogScope _log;

  void start() => _log.info('transcription started');
}

Runtime type names may change with generics, minification, or obfuscation. Use explicit tags and eventName values for stable machine queries.

Structured events

Use a stable eventName; keep summary short and human-readable:

logger.event(
  level: AppLogLevel.error,
  eventName: 'repository.load.failed',
  tag: 'repository',
  summary: 'load failed',
  stage: 'repository',
  action: 'load',
  status: 'failed',
  durationMs: 1200,
  errorCode: 'timeout',
  correlationId: journeyCorrelationId,
  attributes: {
    'page': 2,
    'cached': false,
    'retryCount': 1,
  },
  error: error,
  stackTrace: stackTrace,
);

Structured console output is one prefixed JSON line without ANSI styling:

APP_RUNTIME_LOG {"schemaVersion":"app.runtime-log.v1",...}

The published contract is available at schema/app.runtime-log.v1.schema.json.

Required fields are filled by the logger:

  • schemaVersion, timestamp, level, eventName, tag, summary
  • redactionApplied, truncationApplied

Optional fields are stage, action, status, durationMs, errorCode, correlationId, attributes, error, and stackTrace.

eventName must be lowercase and dot/dash/underscore separated, for example router.navigation.failed. Invalid names become logging.invalid_event without throwing into business code.

Privacy and bounds

The default redactor covers common Authorization/Bearer/Cookie values, token or password assignments (including quoted JSON-style keys and quoted values), JWTs, URL query/fragment data, email addresses, and phone numbers. Credential and header keys are matched case-insensitively with ordinary whitespace around : or =; quoted values may contain escaped characters. Escaped or encoded credential key names and arbitrary prose are outside this conservative safety net. Sensitive attribute names have their values replaced entirely.

Attributes accept only JSON-safe null/bool/finite-number/string/list/map data. The processor limits string lengths, collection sizes, nesting depth, node count, and final serialized event size. Unsupported values are replaced rather than converted with arbitrary toString() calls.

Configured field and collection limits are checked against the published Schema and package safety minimums. Truncation markers are included in those field limits, and maxEventJsonLength must be at least 512 JSON characters. When an event exceeds its total budget, the processor removes attributes, then bounds stack trace, error, and summary text, rechecking the encoded JSON after each step. If the remaining optional metadata still cannot fit, it emits a fixed schema-valid logging.event_truncated fallback.

Sanitization is fail-closed for data: if a redactor or serializer fails, the original payload is dropped and a generic logging.payload_dropped event is emitted. Sink failures remain fail-open for the application.

redactionApplied: false means no configured rule changed the input. It is not proof that arbitrary input is free of sensitive information. Prefer bounded metadata and summaries; do not log complete requests, responses, user content, tokens, passwords, or full URLs.

Provider Observer

AppLoggerProviderObserver is opt-in and should normally be mounted only in debug builds. Its include whitelist applies to every callback:

Callback Level Provider value recorded?
didAddProvider debug No
didDisposeProvider debug No
didUpdateProvider debug No
providerDidFail error Error and stack are sanitized

Failures are breadcrumbs only. Business boundaries still own explicit error reporting, and a sink must never call recordError.

Bounded long diagnostics

debugChunked remains available for temporary, bounded diagnostic text. It sanitizes the complete input before splitting and defaults to at most 16 chunks of 800 characters:

logger.debugChunked('parser', boundedDiagnosticSummary, maxChunks: 4);

It is not a supported mechanism for complete network responses or user data.

Existing custom loggers

An arbitrary implementation of AppLogger cannot be made safe by this package unless calls pass through the package processor. Wrap an existing backend:

final logger = SanitizingAppLogger(
  existingLogger,
  eventSink: boundedEventSink,
);

The delegate receives sanitized legacy text. The optional event sink receives the complete sanitized structured event.

Sink compatibility

AppLogSink.write remains source-compatible and still receives warn/error only. Starting with 0.2.0, its error may be a sanitized String and its stackTrace is reconstructed from sanitized text, so sinks must not rely on the caller's original object identity.

AppLogEventSink receives immutable sanitized events for every allowed level. The package ships no Firebase, local database, or remote shipping implementation.

Testing

final fake = FakeAppLogger();

fake.event(
  level: AppLogLevel.info,
  eventName: 'app.bootstrap.completed',
  tag: 'bootstrap',
  summary: 'ready',
);

expect(fake.events.single.eventName, 'app.bootstrap.completed');
expect(fake.entries.single.message, 'ready');

FakeAppLogger records sanitized values so privacy behavior can be verified in tests. Error and stack object identity is intentionally not preserved.

Out of scope

  • Analytics and crash reporting
  • Log UI or local persistence
  • Remote log shipping
  • Factory Event Log, Gate Fact, Evidence, or Correction Hint semantics
  • Source-token scanning as proof that logging is correctly integrated

License

Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.

Libraries

app_factory_logging
Privacy-safe legacy and structured logging for Flutter applications.