Migrate Observability APIs and Transports

on this page

Use these notes when upgrading to controller 4 and observability 4 through @bluecadet/launchpad 4. The umbrella also requires a major release because its re-exported public APIs have breaking changes. See Versioning and upgrades for the independent package policy.

API changes when upgrading

These TypeScript changes apply even if you keep legacy transports:

Copy log entries when enriching them

LogEntry fields are now readonly. Custom transports and helpers that assign to fields such as entry.module or entry.metadata must create a new entry instead. Copy metadata too when enriching it, so other consumers do not see your changes:

typescript
import type { LogEntry } from '@bluecadet/launchpad/observability';

function enrichEntry(entry: LogEntry): LogEntry {
  return {
    ...entry,
    module: entry.module ?? 'installation',
    metadata: { ...entry.metadata, installation: 'lobby-kiosk' },
  };
}

Pass the returned entry to your exporter rather than mutating the original. Readonly fields do not make nested metadata deeply immutable.

Type transport-only helpers explicitly

ObservabilityConfig is now a union of legacy transport and destination configurations, so transports may be absent. Helpers that only accept transport configurations should use LegacyObservabilityConfig:

typescript
import type { LegacyObservabilityConfig } from '@bluecadet/launchpad/observability';

function getTransports(config: LegacyObservabilityConfig) {
  return config.transports;
}

If a helper accepts both modes, keep ObservabilityConfig and narrow before accessing transports, for example with if (config.transports !== undefined).

Optional migration to destinations

Existing transports configurations are still accepted and keep their in-memory delivery behavior. The new destination defaults described below do not change legacy transport delivery. Switch modes when you need structured Loki records, OTLP, optional resource attributes, gauges, or checkpointed file delivery.

1. Record the current filters and labels

Existing include, exclude, batch, and buffer options can be carried into destination mode. Default file delivery filters only canonical records captured by the controller, not every raw custom bus event. If your filters rely on custom live-bus events, explicitly select memory delivery as described below. Record every legacy Loki label that queries, dashboards, or alerts depend on. Resource attributes are optional and caller-defined; preserving an existing label contract requires an explicit resourceLabels map.

2. Replace the transport

Before:

typescript
import { observability } from '@bluecadet/launchpad/observability';
import { createLokiTransport } from '@bluecadet/launchpad/observability/transports/loki';

observability({
  transports: [
    createLokiTransport({
      url: 'http://localhost:3100',
      defaultLabels: {
        client: 'museum',
        project: 'west-wing',
        installation: 'lobby-kiosk',
        environment: 'production',
        service_name: 'launchpad',
      },
    }),
  ],
});

After:

typescript
import { observability } from '@bluecadet/launchpad/observability';
import { createLokiDestination } from '@bluecadet/launchpad/observability/destinations/loki';

observability({
  resource: {
    'service.name': 'launchpad',
    'launchpad.client': 'museum',
    'launchpad.project': 'west-wing',
    'launchpad.installation': 'lobby-kiosk',
    'deployment.environment.name': 'production',
  },
  destinations: [
    createLokiDestination({
      url: 'http://localhost:3100',
      auth: process.env.LOKI_TOKEN
        ? { type: 'bearer', token: process.env.LOKI_TOKEN }
        : undefined,
      resourceLabels: {
        'launchpad.client': 'client',
        'launchpad.project': 'project',
        'launchpad.installation': 'installation',
        'deployment.environment.name': 'environment',
        'service.name': 'service_name',
      },
    }),
  ],
});

This resource shape and label map are an optional fleet policy, not required observability fields. Omit resource when the runtime defaults for service.name and service.instance.id are sufficient.

Do not leave transports in the new configuration. The two modes are mutually exclusive and mixed configuration fails validation. logStorage is also destination-only; adding either { type: 'file' } or { type: 'memory' } to a legacy transport configuration fails validation. Legacy transports keep their existing in-memory behavior.

3. Check query and ingestion behavior

createLokiTransport() continues to emit legacy plain-text lines. createLokiDestination() emits versioned structured JSON and preserves safely normalized metadata. Update parsers and queries that assume the old line format before switching production traffic.

When resourceLabels is omitted, the destination maps only service.name to service_name. A supplied map replaces that default; it does not extend it. Missing resource attributes are omitted from labels, while present booleans and numbers are stringified. All resource attributes remain in each structured log line regardless of label mapping.

Review selectors in a non-production stream. This release has fixture coverage but does not claim validation against a live Loki or OpenTelemetry backend. For OTLP metrics, queryable Grafana labels also depend on the collector’s resource-to-label mapping.

4. Review default file delivery

Destination mode now uses the controller’s retained canonical JSONL and per-destination checkpoints automatically. Omitting logStorage is equivalent to { type: 'file' }. Set explicit memory delivery for custom exporters without replay support, custom live-bus events not captured in the canonical source, tests without a file source, or ephemeral delivery:

typescript
observability({
  destinations: [createLokiDestination({ url: 'http://localhost:3100' })],
  logStorage: { type: 'memory' },
});

Default file delivery stores an independent checkpoint for each destination. Enrollment begins at the oldest canonical record still retained, so enabling a new destination can backfill historical data, increase ingestion costs, and encounter backend timestamp rejection. Restarting with the same destination name, checkpoint key, and controller source resumes from the existing checkpoint. A lost acknowledgement can duplicate records, and bounded retention can expire unread records. It does not read the old text files or create a separate observability spool; no Alloy or other sidecar is required.

Memory delivery does not advance file checkpoints. Switching back to file delivery resumes from an existing checkpoint or the oldest retained record if none exists, so records already sent from memory may be exported again.

Custom log exporters must provide a stable checkpointKey and implement exportCanonical(batch, context), using the normalized records and historical resource snapshot in the batch, or explicitly opt out with memory delivery. An unavailable canonical source never triggers an automatic fallback to memory. See custom destinations and delivery limits.

The controller’s file layout has changed from three overlapping Winston streams (launchpad-info, launchpad-debug, and launchpad-error) to segmented canonical JSONL plus one optional, default-on human-readable text stream at info and above. Existing files are neither migrated nor deleted. Update file-tail tools, backup rules, and cleanup scripts that depend on the old names or assume one file per severity. The text format is for people and may use the legacy custom formatter; the canonical JSONL schema is fixed and is the machine-readable contract.

The controller now owns one rotation and retention policy for both outputs. Directory and custom text format options remain supported. maxSize accepts byte sizes of at least 1,024 bytes, such as 8m; invalid or smaller sizes fail configuration validation. The default segment target changes from 20m to 8m. maxFiles accepts positive day-based ages such as 28d; count-based and non-day retention values now fail validation and must be replaced before upgrading. A nondefault datePattern is accepted but ignored with one deprecation warning; the default YYYY-MM-DD does not warn. Review the controller logging reference before rollout.

5. Decide whether to export metrics

Metrics are independent of include and exclude and default to a 30-second interval. Use metrics: false for a logs-only migration:

typescript
observability({
  destinations: [createLokiDestination({ url: 'http://localhost:3100' })],
  metrics: false,
});

Loki is logs-only, so use an OTLP destination when gauges are required.

File-backed delivery applies only to logs. Metrics continue to be latest, coalesced in-memory snapshots; file delivery does not backfill metric history after downtime.

6. Keep the old mode available during rollout

Change one configuration mode at a time; do not configure both in one plugin. Legacy transport mode remains available, so rolling back from destinations to transports does not require changing the installed package version. Custom transport code still needs the TypeScript updates described above.