Skip to main content

Logging & Observability

Instrument Starfish sync operations for debugging, monitoring, and performance tracking. All patterns use existing SDK extension points — no source modifications needed.

Prerequisites: SyncManager, Zustand Binding, Integration Patterns

Structured Logger Interface​

Define a typed logger so sync events have a consistent shape:

interface SyncLogger {
pullStart(store: string): void
pullSuccess(store: string, durationMs: number): void
pullError(store: string, error: string): void
pushStart(store: string): void
pushSuccess(store: string, durationMs: number): void
pushError(store: string, error: string): void
conflict(store: string, attempt: number): void
retry(store: string, status: number, delayMs: number): void
}

Console implementation​

const consoleSyncLogger: SyncLogger = {
pullStart: (store) => console.log(`[sync:${store}] pull started`),
pullSuccess: (store, ms) => console.log(`[sync:${store}] pull OK (${ms}ms)`),
pullError: (store, err) => console.error(`[sync:${store}] pull failed:`, err),
pushStart: (store) => console.log(`[sync:${store}] push started`),
pushSuccess: (store, ms) => console.log(`[sync:${store}] push OK (${ms}ms)`),
pushError: (store, err) => console.error(`[sync:${store}] push failed:`, err),
conflict: (store, attempt) => console.warn(`[sync:${store}] conflict (attempt ${attempt})`),
retry: (store, status, delay) => console.log(`[sync:${store}] retry after ${status} in ${delay}ms`),
}

No-op implementation​

Use in production to eliminate logging overhead:

const noopSyncLogger: SyncLogger = {
pullStart: () => {},
pullSuccess: () => {},
pullError: () => {},
pushStart: () => {},
pushSuccess: () => {},
pushError: () => {},
conflict: () => {},
retry: () => {},
}

Custom Fetch Logger​

Inject logging via StarfishClientOptions.fetch to capture every HTTP request:

function createLoggingFetch(
logger: SyncLogger,
storeName: string,
): typeof globalThis.fetch {
return async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
const method = init?.method ?? "GET"
const start = performance.now()

try {
const response = await globalThis.fetch(input, init)
const durationMs = Math.round(performance.now() - start)

if (response.status === 409) {
logger.conflict(storeName, 0)
} else if (response.status === 429 || response.status >= 500) {
logger.retry(storeName, response.status, 0)
}

return response
} catch (err) {
const durationMs = Math.round(performance.now() - start)
logger.pushError(storeName, `${method} ${url} failed after ${durationMs}ms: ${err}`)
throw err
}
}
}

Composing with retry fetch​

The logging fetch wraps the retry fetch from Error Classification & Retry, so you see every attempt:

import { StarfishClient } from "@drakkar.software/starfish-client"

const client = new StarfishClient({
baseUrl: "https://api.example.com/v1",
capProvider: { getCap: async () => ({ cap, devEdPrivHex }) },
// Logging wraps retry wraps native fetch
fetch: createLoggingFetch(
consoleSyncLogger,
"settings",
),
})

If you're also using createRetryFetch or createResilientFetch, compose them:

const baseFetch = createResilientFetch({ maxRetries: 3 })

const loggingFetch: typeof globalThis.fetch = async (input, init) => {
const start = performance.now()
try {
const response = await baseFetch(input, init)
const ms = Math.round(performance.now() - start)
consoleSyncLogger.pushSuccess("settings", ms)
return response
} catch (err) {
consoleSyncLogger.pushError("settings", String(err))
throw err
}
}

Sync Event Tracking​

The Zustand binding uses subscribeWithSelector, so you can detect state transitions and map them to logger calls:

import type { StoreApi } from "zustand/vanilla"
import type { StarfishStore } from "@drakkar.software/starfish-client/zustand"

function attachSyncLogging(
store: StoreApi<StarfishStore>,
logger: SyncLogger,
name: string,
) {
let syncStartedAt = 0
let lastOp: "push" | "pull" = "pull"

store.subscribe(
(state) => state.syncing,
(syncing, prevSyncing) => {
if (syncing && !prevSyncing) {
// Sync started — check dirty to determine operation type
syncStartedAt = performance.now()
const isDirty = store.getState().dirty
lastOp = isDirty ? "push" : "pull"
if (isDirty) {
logger.pushStart(name)
} else {
logger.pullStart(name)
}
}

if (!syncing && prevSyncing) {
// Sync ended
const durationMs = Math.round(performance.now() - syncStartedAt)
const { error, dirty } = store.getState()
if (error) {
lastOp === "push"
? logger.pushError(name, error)
: logger.pullError(name, error)
} else if (dirty) {
logger.pushError(name, "Push completed but data still dirty")
} else {
lastOp === "push"
? logger.pushSuccess(name, durationMs)
: logger.pullSuccess(name, durationMs)
}
}
},
)
}

Usage:

const store = createStarfishStore({ name: "settings", syncManager })
attachSyncLogging(store, consoleSyncLogger, "settings")

Built-In Metrics Collector​

Since v1.5.0, the SDK ships a built-in MetricsCollector that accumulates per-store statistics:

import { createMetricsCollector } from "@drakkar.software/starfish-client"
import type { SyncMetrics } from "@drakkar.software/starfish-client"

const collector = createMetricsCollector()

// Record metrics from your sync logger or manually
collector.recordPull("settings", 145, { bytesTransferred: 2048 })
collector.recordPush("settings", 210, { bytesTransferred: 512 })
collector.recordConflict("settings")

// Get summary for all stores
const summary = collector.getSummary()
// { settings: { totalPulls: 1, totalPushes: 1, avgDurationMs: 178, totalBytes: 2560, totalConflicts: 1 } }

collector.reset() // Clear all accumulated data

The SyncLogger interface also accepts optional SyncMetrics on pullSuccess and pushSuccess:

import { consoleSyncLogger } from "@drakkar.software/starfish-client"

// Metrics are logged alongside timing data
consoleSyncLogger.pullSuccess("settings", 145, {
bytesTransferred: 2048,
cacheHit: true,
})
// → [starfish:settings] pull OK (145ms) 2048B (cache hit)

Custom Performance Metrics​

For more control, implement your own collector:

class SyncMetrics {
private pullCount = 0
private pushCount = 0
private conflictCount = 0
private errorCount = 0
private totalPullMs = 0
private totalPushMs = 0

recordPull(durationMs: number) {
this.pullCount++
this.totalPullMs += durationMs
}

recordPush(durationMs: number) {
this.pushCount++
this.totalPushMs += durationMs
}

recordConflict() {
this.conflictCount++
}

recordError() {
this.errorCount++
}

getReport() {
return {
pulls: this.pullCount,
pushes: this.pushCount,
conflicts: this.conflictCount,
errors: this.errorCount,
avgPullMs: this.pullCount ? Math.round(this.totalPullMs / this.pullCount) : 0,
avgPushMs: this.pushCount ? Math.round(this.totalPushMs / this.pushCount) : 0,
}
}

reset() {
this.pullCount = this.pushCount = this.conflictCount = this.errorCount = 0
this.totalPullMs = this.totalPushMs = 0
}
}

Wire it into a SyncLogger:

const metrics = new SyncMetrics()

const metricsLogger: SyncLogger = {
pullStart: () => {},
pullSuccess: (_, ms) => metrics.recordPull(ms),
pullError: () => metrics.recordError(),
pushStart: () => {},
pushSuccess: (_, ms) => metrics.recordPush(ms),
pushError: () => metrics.recordError(),
conflict: () => metrics.recordConflict(),
retry: () => {},
}

// Check metrics anytime
console.log(metrics.getReport())
// { pulls: 12, pushes: 8, conflicts: 1, errors: 0, avgPullMs: 145, avgPushMs: 210 }

Sending Metrics to Analytics​

Batch metrics and send them periodically:

function startMetricsReporter(
metrics: SyncMetrics,
send: (report: ReturnType<SyncMetrics["getReport"]>) => void,
intervalMs = 60_000,
): () => void {
const timer = setInterval(() => {
const report = metrics.getReport()
if (report.pulls > 0 || report.pushes > 0 || report.errors > 0) {
send(report)
metrics.reset()
}
}, intervalMs)

return () => clearInterval(timer)
}

// Example: send to a generic analytics API
const stopReporting = startMetricsReporter(metrics, (report) => {
analytics.track("starfish_sync_metrics", report)
})

Privacy: only log metadata (timing, counts, status codes). Never log decrypted document contents — the entire point of E2E encryption is that sync infrastructure never sees plaintext.

Combining with Lifecycle Hooks​

The Sync Lifecycle Hooks pattern wraps pull and flush actions on the store. Combine hooks with the logger for complete coverage:

function attachLoggedHooks(
store: StoreApi<StarfishStore>,
logger: SyncLogger,
name: string,
) {
const originalPull = store.getState().pull
const originalFlush = store.getState().flush

store.setState({
pull: async () => {
logger.pullStart(name)
const start = performance.now()
try {
await originalPull()
logger.pullSuccess(name, Math.round(performance.now() - start))
} catch (err) {
logger.pullError(name, (err as Error).message)
throw err
}
},
flush: async () => {
logger.pushStart(name)
const start = performance.now()
try {
await originalFlush()
logger.pushSuccess(name, Math.round(performance.now() - start))
} catch (err) {
logger.pushError(name, (err as Error).message)
throw err
}
},
})
}

Call this once, immediately after store creation — the same caveat as Sync Lifecycle Hooks.

Next Steps​