From 8afb7cce86a8740e81b970e1c45dfbe2c3b55bb7 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Tue, 16 Jun 2026 14:27:44 +0200 Subject: [PATCH 1/9] step isolation --- .changeset/step-isolation-cli.md | 9 + .changeset/step-isolation-collector.md | 9 + .changeset/step-isolation-core.md | 9 + .changeset/step-isolation-gcp.md | 7 + .../unit/commands/run-pipeline-frozen.test.ts | 1 + .../commands/run-pipeline-telemetry.test.ts | 1 + .../unit/commands/run-pipeline.test.ts | 89 +++- .../unit/runtime/health-server.test.ts | 19 + .../run/__tests__/error-redaction.test.ts | 132 +++++ .../commands/run/__tests__/error-sink.test.ts | 145 ++++++ .../commands/run/__tests__/pipeline.test.ts | 390 ++++++++++++++ packages/cli/src/commands/run/error-sink.ts | 91 ++++ packages/cli/src/commands/run/index.ts | 18 + packages/cli/src/commands/run/pipeline.ts | 347 +++++++++++-- .../src/runtime/__tests__/heartbeat.test.ts | 208 ++++++++ .../src/runtime/__tests__/log-ring.test.ts | 137 ++++- .../cli/src/runtime/__tests__/runner.test.ts | 4 + packages/cli/src/runtime/health-server.ts | 34 +- packages/cli/src/runtime/heartbeat.ts | 44 +- packages/cli/src/runtime/log-ring.ts | 77 ++- .../collector/src/__tests__/breaker.test.ts | 475 ++++++++++++++++++ .../src/__tests__/observerEmit.test.ts | 2 + .../src/__tests__/queue-bounds.test.ts | 2 + .../src/__tests__/report-error.test.ts | 191 +++++++ .../store-cache-wrapper.observer.test.ts | 2 + .../collector/src/__tests__/store.test.ts | 2 + .../src/__tests__/transformer-branch.test.ts | 2 + packages/collector/src/breaker.ts | 202 ++++++++ packages/collector/src/collector.ts | 2 + packages/collector/src/destination.ts | 189 +++++-- packages/collector/src/index.ts | 8 + packages/collector/src/on.ts | 8 + packages/collector/src/report-error.ts | 177 +++++++ packages/collector/src/source.ts | 2 + packages/collector/src/store.ts | 2 + packages/collector/src/transformer.ts | 19 + packages/core/src/__tests__/emitStep.test.ts | 2 + packages/core/src/schemas/destination.ts | 12 + packages/core/src/types/collector.ts | 76 ++- packages/core/src/types/context.ts | 25 +- packages/core/src/types/destination.ts | 25 + packages/core/src/types/index.ts | 2 +- .../@google-cloud/bigquery-storage.ts | 79 ++- .../__tests__/bigquery-storage-mock.d.ts | 9 + .../gcp/src/bigquery/__tests__/index.test.ts | 187 ++++++- .../bigquery/__tests__/stepExamples.test.ts | 1 + .../gcp/src/bigquery/__tests__/writer.test.ts | 67 +++ .../destinations/gcp/src/bigquery/index.ts | 60 ++- .../destinations/gcp/src/bigquery/push.ts | 13 + .../gcp/src/bigquery/pushBatch.ts | 11 + .../gcp/src/bigquery/types/index.ts | 39 ++ .../destinations/gcp/src/bigquery/writer.ts | 180 ++++++- .../express/src/__tests__/index.test.ts | 95 ++++ .../SKILL.md | 25 + 54 files changed, 3824 insertions(+), 140 deletions(-) create mode 100644 .changeset/step-isolation-cli.md create mode 100644 .changeset/step-isolation-collector.md create mode 100644 .changeset/step-isolation-core.md create mode 100644 .changeset/step-isolation-gcp.md create mode 100644 packages/cli/src/commands/run/__tests__/error-redaction.test.ts create mode 100644 packages/cli/src/commands/run/__tests__/error-sink.test.ts create mode 100644 packages/cli/src/commands/run/error-sink.ts create mode 100644 packages/collector/src/__tests__/breaker.test.ts create mode 100644 packages/collector/src/__tests__/report-error.test.ts create mode 100644 packages/collector/src/breaker.ts create mode 100644 packages/collector/src/report-error.ts diff --git a/.changeset/step-isolation-cli.md b/.changeset/step-isolation-cli.md new file mode 100644 index 000000000..c40a55bc5 --- /dev/null +++ b/.changeset/step-isolation-cli.md @@ -0,0 +1,9 @@ +--- +'@walkeros/cli': patch +--- + +The runner registers its process-error guards before startup and degrades its +readiness check after repeated out-of-band errors, so a wedged container is +recycled instead of silently hot-looping. Heartbeats now flush immediately on a +new error and on shutdown, persist errors to disk so a failure cause survives a +restart, and report their configured interval. diff --git a/.changeset/step-isolation-collector.md b/.changeset/step-isolation-collector.md new file mode 100644 index 000000000..140f39c5b --- /dev/null +++ b/.changeset/step-isolation-collector.md @@ -0,0 +1,9 @@ +--- +'@walkeros/collector': patch +--- + +Add a per-destination circuit breaker that skips a destination after consecutive +transport failures and probes once after a cooldown, so a persistently failing +destination stops retrying on every event. Out-of-band `reportError` calls are +routed to the dead-letter queue (when an event is in hand) or counted as +connection errors and surfaced in status. diff --git a/.changeset/step-isolation-core.md b/.changeset/step-isolation-core.md new file mode 100644 index 000000000..cf9e8a0a3 --- /dev/null +++ b/.changeset/step-isolation-core.md @@ -0,0 +1,9 @@ +--- +'@walkeros/core': patch +--- + +Add an optional `reportError` callback to the step context so any source, +transformer, store, or destination can report an out-of-band error (for example +from an SDK's event emitter) into the pipeline's failure handling. Add an +optional per-destination `breaker` config to skip a destination after repeated +transport failures. diff --git a/.changeset/step-isolation-gcp.md b/.changeset/step-isolation-gcp.md new file mode 100644 index 000000000..e26e81e80 --- /dev/null +++ b/.changeset/step-isolation-gcp.md @@ -0,0 +1,7 @@ +--- +'@walkeros/server-destination-gcp': patch +--- + +Capture BigQuery Storage Write stream errors so a broken writer routes events to +the dead-letter queue instead of crashing the process, and re-open a broken +writer automatically on the next event. diff --git a/packages/cli/src/__tests__/unit/commands/run-pipeline-frozen.test.ts b/packages/cli/src/__tests__/unit/commands/run-pipeline-frozen.test.ts index dec0e85f8..acca1888c 100644 --- a/packages/cli/src/__tests__/unit/commands/run-pipeline-frozen.test.ts +++ b/packages/cli/src/__tests__/unit/commands/run-pipeline-frozen.test.ts @@ -19,6 +19,7 @@ jest.mock('../../../runtime/health-server.js', () => ({ setFlowHandler: jest.fn(), setReady: jest.fn(), setFailed: jest.fn(), + setDegraded: jest.fn(), close: jest.fn().mockResolvedValue(undefined), }), })); diff --git a/packages/cli/src/__tests__/unit/commands/run-pipeline-telemetry.test.ts b/packages/cli/src/__tests__/unit/commands/run-pipeline-telemetry.test.ts index 197df3e35..c6a7aa183 100644 --- a/packages/cli/src/__tests__/unit/commands/run-pipeline-telemetry.test.ts +++ b/packages/cli/src/__tests__/unit/commands/run-pipeline-telemetry.test.ts @@ -41,6 +41,7 @@ jest.mock('../../../runtime/health-server.js', () => ({ setFlowHandler: jest.fn(), setReady: jest.fn(), setFailed: jest.fn(), + setDegraded: jest.fn(), close: jest.fn().mockResolvedValue(undefined), }), })); diff --git a/packages/cli/src/__tests__/unit/commands/run-pipeline.test.ts b/packages/cli/src/__tests__/unit/commands/run-pipeline.test.ts index 50fe43570..fb37757fc 100644 --- a/packages/cli/src/__tests__/unit/commands/run-pipeline.test.ts +++ b/packages/cli/src/__tests__/unit/commands/run-pipeline.test.ts @@ -7,6 +7,7 @@ jest.mock('../../../runtime/health-server.js', () => ({ setFlowHandler: jest.fn(), setReady: jest.fn(), setFailed: jest.fn(), + setDegraded: jest.fn(), close: jest.fn().mockResolvedValue(undefined), }), })); @@ -62,37 +63,37 @@ import { loadFlow } from '../../../runtime/runner.js'; import { createHeartbeat } from '../../../runtime/heartbeat.js'; import { createPoller } from '../../../runtime/poller.js'; import { fetchSecrets } from '../../../runtime/secrets-fetcher.js'; +import { createMockLogger } from '../../helpers/mock-logger.js'; -const mockLogger = { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - scope: jest.fn().mockReturnThis(), -}; +const mockLogger = createMockLogger(); describe('runPipeline', () => { let runPipeline: typeof import('../../../commands/run/pipeline.js').runPipeline; - let originalProcessOn: typeof process.on; + let resetProcessGuardsForTest: typeof import('../../../commands/run/pipeline.js').resetProcessGuardsForTest; + let processOnMock: jest.SpyInstance; beforeEach(async () => { jest.clearAllMocks(); - // Prevent actual signal handlers and process.exit - originalProcessOn = process.on; - process.on = jest.fn() as any; + // Prevent actual signal handlers and process.exit; spyOn keeps the real + // signature so .mock.calls / .mock.invocationCallOrder stay typed. + processOnMock = jest.spyOn(process, 'on').mockReturnValue(process); // Dynamic import to get fresh module with mocks const mod = await import('../../../commands/run/pipeline.js'); runPipeline = mod.runPipeline; + resetProcessGuardsForTest = mod.resetProcessGuardsForTest; + // The guard latch is module-global and survives across tests in this file; + // reset it so each runPipeline registers its guards fresh. + resetProcessGuardsForTest(); }); afterEach(() => { - process.on = originalProcessOn; + processOnMock.mockRestore(); }); const baseOptions: PipelineOptions = { bundlePath: '/tmp/test-bundle.mjs', port: 8080, - logger: mockLogger as any, + logger: mockLogger, }; it('creates health server and loads flow in standalone mode', async () => { @@ -119,6 +120,68 @@ describe('runPipeline', () => { expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function)); }); + it('registers the process error guards BEFORE the health server and flow load', async () => { + // The net must be up before any construction (health server, flow load, + // openWriter) so an init-window stray emit lands in the guard instead of + // crashing the container. process.on is mocked here, so the guard's + // registration of 'uncaughtException' is captured and we can compare call + // order against the health-server/load-flow mocks. This is the first test + // to drive runPipeline, so the one-shot guard latch still registers fresh. + runPipeline(baseOptions); + await new Promise((r) => setTimeout(r, 50)); + + const guardCall = processOnMock.mock.calls.findIndex( + ([event]) => event === 'uncaughtException', + ); + expect(guardCall).toBeGreaterThanOrEqual(0); + const guardOrder = processOnMock.mock.invocationCallOrder[guardCall]; + + const healthMock = jest.mocked(createHealthServer); + const loadMock = jest.mocked(loadFlow); + const healthOrder = healthMock.mock.invocationCallOrder[0]; + const loadOrder = loadMock.mock.invocationCallOrder[0]; + + expect(guardOrder).toBeLessThan(healthOrder); + expect(guardOrder).toBeLessThan(loadOrder); + }); + + it('auto-degrades /ready after a sustained out-of-band error loop, not a single stray', async () => { + const mockSetDegraded = jest.fn(); + (createHealthServer as jest.Mock).mockResolvedValue({ + server: {}, + setFlowHandler: jest.fn(), + setReady: jest.fn(), + setFailed: jest.fn(), + setDegraded: mockSetDegraded, + close: jest.fn().mockResolvedValue(undefined), + }); + + runPipeline(baseOptions); + await new Promise((r) => setTimeout(r, 50)); + + // Extract the guard's uncaughtException listener that runPipeline registered. + const guardEntry = processOnMock.mock.calls.find( + ([event]) => event === 'uncaughtException', + ); + expect(guardEntry).toBeDefined(); + const guard = guardEntry?.[1]; + expect(typeof guard).toBe('function'); + if (typeof guard !== 'function') throw new Error('guard not a function'); + + // A single stray error self-heals (stays 200). + guard(new Error('stray')); + expect(mockSetDegraded).not.toHaveBeenCalled(); + + // Four more within the window cross the threshold (5) and degrade. + guard(new Error('e2')); + guard(new Error('e3')); + guard(new Error('e4')); + expect(mockSetDegraded).not.toHaveBeenCalled(); + guard(new Error('e5')); + expect(mockSetDegraded).toHaveBeenCalledTimes(1); + expect(mockSetDegraded).toHaveBeenCalledWith('out-of-band error loop'); + }); + it('enables heartbeat, poller, and secrets when api config provided', async () => { const apiOptions: PipelineOptions = { ...baseOptions, diff --git a/packages/cli/src/__tests__/unit/runtime/health-server.test.ts b/packages/cli/src/__tests__/unit/runtime/health-server.test.ts index 1e770a163..6c3dd693f 100644 --- a/packages/cli/src/__tests__/unit/runtime/health-server.test.ts +++ b/packages/cli/src/__tests__/unit/runtime/health-server.test.ts @@ -88,6 +88,25 @@ describe('createHealthServer', () => { expect(JSON.parse(res.body)).toEqual({ status: 'not_ready' }); }); + it('responds 503 with a degraded status and reason after setDegraded', async () => { + server.setReady(true); + server.setDegraded('out-of-band error hot loop'); + const res = await fetch(port, '/ready'); + expect(res.status).toBe(503); + expect(JSON.parse(res.body)).toEqual({ + status: 'degraded', + reason: 'out-of-band error hot loop', + }); + }); + + it('clears a degraded state once setReady(true) is called again', async () => { + server.setDegraded('out-of-band error hot loop'); + server.setReady(true); + const res = await fetch(port, '/ready'); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ status: 'ready' }); + }); + it('delegates non-health requests to flow handler', async () => { server.setFlowHandler((_req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); diff --git a/packages/cli/src/commands/run/__tests__/error-redaction.test.ts b/packages/cli/src/commands/run/__tests__/error-redaction.test.ts new file mode 100644 index 000000000..2c54dea14 --- /dev/null +++ b/packages/cli/src/commands/run/__tests__/error-redaction.test.ts @@ -0,0 +1,132 @@ +import { mkdtempSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Level } from '@walkeros/core'; +import type { Logger } from '@walkeros/core'; +import { createCLILogger } from '../../../core/cli-logger.js'; +import { ErrorRing } from '../../../runtime/index.js'; +import { createHeartbeat } from '../../../runtime/heartbeat.js'; +import { errorSinkPath } from '../error-sink.js'; + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), 'walkeros-redact-')); +} + +/** Typed 200-OK fetch mock, matching the heartbeat suite's helper. */ +function createFetchMock(): jest.Mock< + ReturnType, + Parameters +> { + return jest.fn, Parameters>(() => + Promise.resolve(new Response(null, { status: 200 })), + ); +} + +function silentLogger(): Logger.Instance { + const logger: Logger.Instance = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + throw: (message: string | Error): never => { + throw new Error(typeof message === 'string' ? message : message.message); + }, + json: jest.fn(), + scope: (_name: string): Logger.Instance => logger, + }; + return logger; +} + +/** + * Full path: logger.error(secret) → cli-logger handler (scrubSecrets) → onLine → + * ErrorRing.add (durable jsonl append) → heartbeat sendOnce (redactErrors) → POST + * body. Asserts NO PEM marker and NO private_key VALUE survives in EITHER the + * flushed heartbeat body or the persisted jsonl line. + */ +describe('error redaction end-to-end (logger → ring → jsonl + heartbeat)', () => { + const originalFetch = globalThis.fetch; + const originalConsoleError = console.error; + + beforeEach(() => { + // Silence the handler's chalk console.error so the test output stays clean. + console.error = jest.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + console.error = originalConsoleError; + jest.restoreAllMocks(); + }); + + it('redacts a PEM block and a private_key JSON field across the jsonl and the heartbeat body', async () => { + const dir = tempDir(); + const sink = errorSinkPath(dir); + + const ring = new ErrorRing(20); + ring.setSink(sink); + + // Build the runner logger with the SAME onLine ring tap the run command uses. + const onLine = (level: Level, message: string) => { + if (level === Level.ERROR) ring.add(message); + }; + const logger = createCLILogger({ silent: true, onLine }); + + // The realistic deployed shape: a destination logs the raw PEM as its own + // message (BEGIN marker at line start). scrubSecrets removes the whole block + // structurally, marker included. + const pemKey = + '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ\n-----END PRIVATE KEY-----'; + // A service-account blob with an embedded private_key field (\n-encoded PEM + // body). The JSON-SA-field pass masks the field VALUE before any line split. + const saJson = + 'GCP auth failed {"type":"service_account","private_key":"-----BEGIN PRIVATE KEY-----\\nMIIEsecretsecretsecret\\n-----END PRIVATE KEY-----\\n","client_email":"x@y.iam.gserviceaccount.com"}'; + + logger.error(pemKey); + logger.error(saJson); + + // ── jsonl assertions ────────────────────────────────────────────────── + const jsonl = readFileSync(sink, 'utf-8'); + // Line-start PEM block is removed wholesale (marker + body). + expect(jsonl).not.toContain('-----BEGIN'); + expect(jsonl).not.toContain('MIIEvQIBAD'); // PEM body must not leak + // The private_key field VALUE (the actual secret) must not survive. The bare + // JSON field name is left as `"private_key":"***"`, so assert the VALUE, not + // the field name. + expect(jsonl).not.toContain('MIIEsecret'); // SA private_key body must not leak + + // ── heartbeat body assertions ───────────────────────────────────────── + const fetchMock = createFetchMock(); + globalThis.fetch = fetchMock; + + const heartbeat = createHeartbeat( + { + appUrl: 'http://localhost:3000', + token: 'bearer-test', + projectId: 'proj_1', + intervalMs: 60000, + getErrors: () => ring.snapshot(), + }, + silentLogger(), + ); + + await heartbeat.sendOnce(); + + const init = fetchMock.mock.calls[0]?.[1]; + const body = init?.body; + if (typeof body !== 'string') { + throw new Error('expected a string request body'); + } + expect(body).not.toContain('-----BEGIN'); + expect(body).not.toContain('MIIEvQIBAD'); + expect(body).not.toContain('MIIEsecret'); + // The private_key VALUE must be gone from the body too. + expect(body).not.toContain('MIIEsecretsecretsecret'); + + // Sanity: errors did egress (redacted), so the assertions above are real. + const parsed: { recentErrors?: Array<{ message: string }> } = + JSON.parse(body); + expect(parsed.recentErrors && parsed.recentErrors.length).toBeGreaterThan( + 0, + ); + }); +}); diff --git a/packages/cli/src/commands/run/__tests__/error-sink.test.ts b/packages/cli/src/commands/run/__tests__/error-sink.test.ts new file mode 100644 index 000000000..df7a3383e --- /dev/null +++ b/packages/cli/src/commands/run/__tests__/error-sink.test.ts @@ -0,0 +1,145 @@ +import { mkdtempSync, readFileSync, writeFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { ErrorRing } from '../../../runtime/index.js'; +import { + ensureSinkDir, + errorSinkPath, + seedErrorRingFromJsonl, +} from '../error-sink.js'; + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), 'walkeros-error-sink-')); +} + +describe('errorSinkPath', () => { + it('resolves errors.jsonl inside the cache dir', () => { + expect(errorSinkPath('/var/cache/walkeros')).toBe( + '/var/cache/walkeros/errors.jsonl', + ); + }); +}); + +describe('ensureSinkDir + sink persistence on a fresh container', () => { + it('creates a not-yet-existing cache dir so the first append persists the line', () => { + // Mirror the managed-runner boot: the cacheDir does NOT exist yet (no + // writeCache ran). ensureSinkDir must create it so ErrorRing.persist's + // synchronous append succeeds rather than throwing ENOENT into the swallow. + const base = tempDir(); + const cacheDir = join(base, 'nested', 'walkeros-cache'); + expect(existsSync(cacheDir)).toBe(false); + + ensureSinkDir(cacheDir); + expect(existsSync(cacheDir)).toBe(true); + + const sink = errorSinkPath(cacheDir); + let t = 777; + const ring = new ErrorRing(10, () => t); + ring.setSink(sink); + ring.add('boom'); + + expect(existsSync(sink)).toBe(true); + const lines = readFileSync(sink, 'utf-8') + .split('\n') + .filter((l) => l.length > 0); + expect(lines).toHaveLength(1); + const record: { message: string; firstSeen: number } = JSON.parse(lines[0]); + expect(record.message).toBe('boom'); + expect(record.firstSeen).toBe(777); + }); + + it('swallows a genuinely unwritable path (cacheDir under a file, not a dir)', () => { + // Point the cacheDir at a path whose parent is a regular FILE, so mkdir + // cannot create it (ENOTDIR) and a later append also fails. Both must be + // swallowed: ensureSinkDir must not throw, and the ring must keep working. + const base = tempDir(); + const filePath = join(base, 'not-a-dir'); + writeFileSync(filePath, 'i am a file'); + const cacheDir = join(filePath, 'cache'); // under a file → ENOTDIR + + expect(() => ensureSinkDir(cacheDir)).not.toThrow(); + expect(existsSync(cacheDir)).toBe(false); + + const sink = errorSinkPath(cacheDir); + let t = 0; + const ring = new ErrorRing(10, () => t++); + ring.setSink(sink); + + expect(() => ring.add('boom')).not.toThrow(); + expect(ring.snapshot().map((e) => e.message)).toContain('boom'); + expect(existsSync(sink)).toBe(false); + }); +}); + +describe('seedErrorRingFromJsonl', () => { + it('seeds prior errors into the ring then truncates the file', () => { + const dir = tempDir(); + const sink = join(dir, 'errors.jsonl'); + writeFileSync( + sink, + [ + JSON.stringify({ message: 'prior boom', firstSeen: 111 }), + JSON.stringify({ message: 'prior crash', firstSeen: 222 }), + ].join('\n') + '\n', + ); + + let t = 1000; + const ring = new ErrorRing(10, () => t); + seedErrorRingFromJsonl(ring, sink); + + const messages = ring.snapshot().map((e) => e.message); + expect(messages).toContain('prior boom'); + expect(messages).toContain('prior crash'); + // firstSeen preserved from the persisted record (not the boot clock). + const boom = ring.snapshot().find((e) => e.message === 'prior boom'); + expect(boom?.firstSeen).toBe(111); + + // File is truncated so a following boot re-ships nothing. + expect(readFileSync(sink, 'utf-8')).toBe(''); + }); + + it('is not re-shipped on the following boot (truncated file seeds nothing)', () => { + const dir = tempDir(); + const sink = join(dir, 'errors.jsonl'); + writeFileSync( + sink, + JSON.stringify({ message: 'once', firstSeen: 5 }) + '\n', + ); + + const ring1 = new ErrorRing(10, () => 0); + seedErrorRingFromJsonl(ring1, sink); // truncates + + const ring2 = new ErrorRing(10, () => 0); + seedErrorRingFromJsonl(ring2, sink); // second boot reads the truncated file + expect(ring2.snapshot()).toHaveLength(0); + }); + + it('ignores a missing file (nothing seeded, no file created)', () => { + const dir = tempDir(); + const sink = join(dir, 'errors.jsonl'); + + const ring = new ErrorRing(10, () => 0); + expect(() => seedErrorRingFromJsonl(ring, sink)).not.toThrow(); + expect(ring.snapshot()).toHaveLength(0); + expect(existsSync(sink)).toBe(false); + }); + + it('skips corrupt lines but seeds the valid ones', () => { + const dir = tempDir(); + const sink = join(dir, 'errors.jsonl'); + writeFileSync( + sink, + [ + 'not json at all', + JSON.stringify({ message: 'valid', firstSeen: 9 }), + JSON.stringify({ notAnError: true }), + ].join('\n') + '\n', + ); + + const ring = new ErrorRing(10, () => 0); + seedErrorRingFromJsonl(ring, sink); + + const messages = ring.snapshot().map((e) => e.message); + expect(messages).toEqual(['valid']); + }); +}); diff --git a/packages/cli/src/commands/run/__tests__/pipeline.test.ts b/packages/cli/src/commands/run/__tests__/pipeline.test.ts index d12a25707..f8c9f62e8 100644 --- a/packages/cli/src/commands/run/__tests__/pipeline.test.ts +++ b/packages/cli/src/commands/run/__tests__/pipeline.test.ts @@ -1,13 +1,19 @@ import { createLogger, Level, type Logger } from '@walkeros/core'; +import { EventEmitter } from 'events'; import type { PipelineOptions } from '../pipeline.js'; import { + createOutOfBandErrorTracker, handleUnhandledRejection, handleUncaughtException, registerProcessGuards, + resetProcessGuardsForTest, resolveInitialEtag, + runShutdown, shouldStartHeartbeat, shouldStartPoller, + type ShutdownDeps, } from '../pipeline.js'; +import type { HeartbeatHandle } from '../../../runtime/heartbeat.js'; const fakeApi: NonNullable = { appUrl: 'https://app.example', @@ -81,6 +87,161 @@ describe('shouldStartPoller', () => { }); }); +describe('runShutdown (extracted orchestrator)', () => { + function makeLogger(): Logger.Instance { + const base = createLogger({ level: Level.DEBUG }); + return { + ...base, + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }; + } + + /** A minimal HeartbeatHandle whose methods are spies and whose sendOnce can + * be made slow/rejecting for the timeout assertions. */ + function makeHeartbeat(sendOnce: () => Promise): { + handle: HeartbeatHandle; + order: string[]; + } { + const order: string[] = []; + const handle: HeartbeatHandle = { + start: jest.fn(), + stop: jest.fn(() => { + order.push('stop'); + }), + sendOnce: jest.fn(() => { + order.push('sendOnce'); + return sendOnce(); + }), + flushSoon: jest.fn(), + updateConfigVersion: jest.fn(), + }; + return { handle, order }; + } + + function baseDeps(overrides: Partial): ShutdownDeps { + return { + tracePoller: null, + poller: null, + heartbeat: null, + collector: { command: undefined, status: undefined }, + healthServer: { + setReady: jest.fn(), + setFailed: jest.fn(), + setDegraded: jest.fn(), + close: jest.fn(() => Promise.resolve()), + }, + logger: makeLogger(), + exit: jest.fn(), + cleanupTempFiles: () => Promise.resolve(), + forceTimeoutMs: 15000, + ...overrides, + }; + } + + it('awaits the final heartbeat sendOnce BEFORE heartbeat.stop()', async () => { + const { handle, order } = makeHeartbeat(() => Promise.resolve()); + const exit = jest.fn(); + + await runShutdown('SIGTERM', baseDeps({ heartbeat: handle, exit })); + + expect(order).toEqual(['sendOnce', 'stop']); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('sends the final beat before collector shutdown (pre-drain counters)', async () => { + const calls: string[] = []; + const { handle } = makeHeartbeat(() => { + calls.push('sendOnce'); + return Promise.resolve(); + }); + const collector: ShutdownDeps['collector'] = { + command: jest.fn(() => { + calls.push('collector-shutdown'); + return Promise.resolve(); + }), + status: undefined, + }; + + await runShutdown( + 'SIGTERM', + baseDeps({ heartbeat: handle, collector, exit: jest.fn() }), + ); + + expect(calls).toEqual(['sendOnce', 'collector-shutdown']); + }); + + it('swallows a rejecting final send and still completes a clean exit', async () => { + const { handle, order } = makeHeartbeat(() => + Promise.reject(new Error('POST blew up')), + ); + const exit = jest.fn(); + + await runShutdown('SIGTERM', baseDeps({ heartbeat: handle, exit })); + + // sendOnce rejected but was swallowed: stop still ran and exit(0) fired. + expect(order).toEqual(['sendOnce', 'stop']); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('forces exit(1) within the force-timer when a step hangs past the deadline', async () => { + jest.useFakeTimers(); + try { + const exit = jest.fn(); + // healthServer.close never resolves → shutdown hangs; the force-timer must + // fire exit(1) at forceTimeoutMs. + const deps = baseDeps({ + exit, + forceTimeoutMs: 15000, + healthServer: { + setReady: jest.fn(), + setFailed: jest.fn(), + setDegraded: jest.fn(), + close: jest.fn(() => new Promise(() => {})), + }, + }); + + runShutdown('SIGTERM', deps); + expect(exit).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(15000); + expect(exit).toHaveBeenCalledWith(1); + } finally { + jest.useRealTimers(); + } + }); + + it('stops tracePoller and poller before the final beat', async () => { + const calls: string[] = []; + const tracePoller = { + start: jest.fn(), + stop: jest.fn(() => { + calls.push('tracePoller.stop'); + }), + pollOnce: jest.fn(() => Promise.resolve()), + }; + const poller = { + start: jest.fn(), + stop: jest.fn(() => { + calls.push('poller.stop'); + }), + pollOnce: jest.fn(() => Promise.resolve()), + }; + const { handle } = makeHeartbeat(() => { + calls.push('sendOnce'); + return Promise.resolve(); + }); + + await runShutdown( + 'SIGTERM', + baseDeps({ tracePoller, poller, heartbeat: handle, exit: jest.fn() }), + ); + + expect(calls).toEqual(['tracePoller.stop', 'poller.stop', 'sendOnce']); + }); +}); + describe('process error guards', () => { function makeLogger(): { logger: Logger.Instance; errors: string[] } { const errors: string[] = []; @@ -94,6 +255,19 @@ describe('process error guards', () => { return { logger, errors }; } + // A logger whose `error` throws synchronously, to prove the guard handlers + // (the registered uncaughtException/unhandledRejection listeners) never let a + // throw escape — an escape would itself crash the process. + function makeThrowingLogger(): Logger.Instance { + const base = createLogger({ level: Level.DEBUG }); + return { + ...base, + error: () => { + throw new Error('logger.error blew up'); + }, + }; + } + describe('handleUnhandledRejection', () => { it('logs the rejection reason and keeps the process serving', () => { const { logger, errors } = makeLogger(); @@ -116,6 +290,40 @@ describe('process error guards', () => { ); expect(exit).not.toHaveBeenCalled(); }); + + it('feeds the out-of-band error hook so a later task can count it', () => { + const { logger } = makeLogger(); + const onOutOfBandError = jest.fn(); + + handleUnhandledRejection(new Error('stray reject'), { + logger, + exit: jest.fn(), + onOutOfBandError, + }); + + expect(onOutOfBandError).toHaveBeenCalledTimes(1); + }); + + it('does not throw when the logger or degrade hook throws (process survives)', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + // logger.error throws AND the degrade hook throws: neither may escape. + expect(() => + handleUnhandledRejection(new Error('stray reject'), { + logger: makeThrowingLogger(), + exit: jest.fn(), + onOutOfBandError: () => { + throw new Error('degrade hook blew up'); + }, + }), + ).not.toThrow(); + expect(consoleError).toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); }); describe('handleUncaughtException', () => { @@ -128,12 +336,64 @@ describe('process error guards', () => { expect(errors.some((line) => line.includes('stray throw'))).toBe(true); expect(exit).not.toHaveBeenCalled(); }); + + it('feeds the out-of-band error hook so a later task can count it', () => { + const { logger } = makeLogger(); + const onOutOfBandError = jest.fn(); + + handleUncaughtException(new Error('stray throw'), { + logger, + exit: jest.fn(), + onOutOfBandError, + }); + + expect(onOutOfBandError).toHaveBeenCalledTimes(1); + }); + + it('does not throw when the logger or degrade hook throws (process survives)', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + expect(() => + handleUncaughtException(new Error('stray throw'), { + logger: makeThrowingLogger(), + exit: jest.fn(), + onOutOfBandError: () => { + throw new Error('degrade hook blew up'); + }, + }), + ).not.toThrow(); + expect(consoleError).toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); }); describe('registerProcessGuards', () => { + let preexistingException: NodeJS.UncaughtExceptionListener[] = []; + let preexistingRejection: NodeJS.UnhandledRejectionListener[] = []; + + beforeEach(() => { + // Snapshot any guards the test harness/runner already attached, isolate + // this test to its own listeners, and reset the one-shot latch so each + // test registers fresh. + preexistingException = process.listeners('uncaughtException'); + preexistingRejection = process.listeners('unhandledRejection'); + process.removeAllListeners('uncaughtException'); + process.removeAllListeners('unhandledRejection'); + resetProcessGuardsForTest(); + }); + afterEach(() => { process.removeAllListeners('unhandledRejection'); process.removeAllListeners('uncaughtException'); + for (const listener of preexistingException) + process.on('uncaughtException', listener); + for (const listener of preexistingRejection) + process.on('unhandledRejection', listener); + resetProcessGuardsForTest(); }); it('registers one listener per event', () => { @@ -170,5 +430,135 @@ describe('process error guards', () => { afterFirst.exception, ); }); + + it('routes a real listener-less emit("error") into the error path via the registered guard, no rethrow (honesty test)', () => { + // A gRPC StreamConnection is an EventEmitter; a listener-less + // emit('error', ...) on a detached tick is what becomes the + // uncaughtException that bypasses every promise-path try/catch. Here we + // (1) capture the EXACT error a real listener-less emit throws, then + // (2) drive the guard listener that runPipeline registers FIRST with it, + // asserting the guard logs it and does NOT rethrow. Before the change, + // the guards were registered LAST (after loadFlow/openWriter), so an + // init-window emit had no listener to land in — this test pins the + // registered-first contract. + const { logger, errors } = makeLogger(); + const onOutOfBandError = jest.fn(); + registerProcessGuards(logger, onOutOfBandError); + + // Capture the EXACT value a real EventEmitter throws when it emits + // 'error' with no 'error' listener — the genuine out-of-band mechanism, + // not a hand-rolled Error. (Note: under jsdom the thrown value can fail a + // cross-realm `instanceof Error`, so we duck-type the error shape.) + const emitter = new EventEmitter(); + let captured: unknown; + let threw = false; + try { + emitter.emit('error', new Error('grpc stream error')); + } catch (error) { + threw = true; + captured = error; + } + expect(threw).toBe(true); + // Under jsdom the thrown value can fail a cross-realm `instanceof Error`, + // so duck-type the error shape and lift its message into a same-realm + // Error to feed the strongly-typed uncaughtException listener (which only + // reads `.message`). The error-shape + message assertions keep this an + // honest exercise of the real EventEmitter throw. + expect(Object.prototype.toString.call(captured)).toBe('[object Error]'); + const capturedMessage = + typeof captured === 'object' && + captured !== null && + 'message' in captured && + typeof captured.message === 'string' + ? captured.message + : ''; + expect(capturedMessage).toContain('grpc stream error'); + + const guard = process.listeners('uncaughtException').at(-1); + expect(guard).toBeDefined(); + + // Must not rethrow: invoking the registered guard (the one runPipeline + // installs FIRST) returns normally — the process survives. + expect(() => + guard?.(new Error(capturedMessage), 'uncaughtException'), + ).not.toThrow(); + expect(errors.some((line) => line.includes('grpc stream error'))).toBe( + true, + ); + expect(onOutOfBandError).toHaveBeenCalledTimes(1); + }); + + it('exposes a setter that wires the out-of-band hook after registration', () => { + const { logger } = makeLogger(); + const onOutOfBandError = jest.fn(); + + const handle = registerProcessGuards(logger); + handle.setOnOutOfBandError(onOutOfBandError); + + const exception = process.listeners('uncaughtException').at(-1); + expect(exception).toBeDefined(); + exception?.(new Error('boom'), 'uncaughtException'); + + expect(onOutOfBandError).toHaveBeenCalledTimes(1); + }); + }); + + describe('createOutOfBandErrorTracker', () => { + it('fires the degrade callback only after N errors within the window', () => { + let now = 0; + const onThresholdExceeded = jest.fn(); + const tracker = createOutOfBandErrorTracker({ + threshold: 5, + windowMs: 60_000, + now: () => now, + onThresholdExceeded, + }); + + // Four errors: still healthy (self-heals). + for (let i = 0; i < 4; i += 1) tracker.record(); + expect(onThresholdExceeded).not.toHaveBeenCalled(); + + // Fifth error within window: degrade. + tracker.record(); + expect(onThresholdExceeded).toHaveBeenCalledTimes(1); + }); + + it('does not degrade when errors are spread beyond the window', () => { + let now = 0; + const onThresholdExceeded = jest.fn(); + const tracker = createOutOfBandErrorTracker({ + threshold: 5, + windowMs: 60_000, + now: () => now, + onThresholdExceeded, + }); + + // One stray error every 20s: never 5 within any 60s window. + for (let i = 0; i < 10; i += 1) { + tracker.record(); + now += 20_000; + } + + expect(onThresholdExceeded).not.toHaveBeenCalled(); + }); + + it('fires once per crossing, not on every subsequent error', () => { + let now = 0; + const onThresholdExceeded = jest.fn(); + const tracker = createOutOfBandErrorTracker({ + threshold: 3, + windowMs: 60_000, + now: () => now, + onThresholdExceeded, + }); + + tracker.record(); + tracker.record(); + tracker.record(); // crosses + tracker.record(); // still degraded, no second fire + tracker.record(); + + expect(onThresholdExceeded).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/cli/src/commands/run/error-sink.ts b/packages/cli/src/commands/run/error-sink.ts new file mode 100644 index 000000000..e19422a26 --- /dev/null +++ b/packages/cli/src/commands/run/error-sink.ts @@ -0,0 +1,91 @@ +/** + * Durable error sink boot helpers. + * + * The runner persists each first-seen error to `/errors.jsonl` + * synchronously (see `ErrorRing.setSink`) so a crash between heartbeats does not + * lose the failure. On the next boot, this module reads that file, seeds the + * messages back into the ring (so the first heartbeat re-reports the pre-crash + * error), then truncates the file so the same errors are not re-shipped on every + * subsequent boot. + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import type { ErrorRing } from '../../runtime/index.js'; + +/** Resolve the durable jsonl path inside a cache directory. */ +export function errorSinkPath(cacheDir: string): string { + return join(cacheDir, 'errors.jsonl'); +} + +/** + * Ensure the cache directory exists so the synchronous jsonl append can succeed. + * + * A managed runner container boots with a prebuilt bundle (Case 1) and refuses + * to self-bundle, so `writeCache` (the only other dir creator) never runs. On a + * fresh container the dir would be missing, so every `appendFileSync` in + * `ErrorRing.persist` would throw ENOENT into its swallowed catch and durable + * persistence would silently never happen in the exact deployment it targets. + * Best-effort: a failed mkdir leaves persistence disabled (the append swallow + * keeps the ring working either way). + */ +export function ensureSinkDir(cacheDir: string): void { + try { + mkdirSync(cacheDir, { recursive: true }); + } catch { + // Best-effort: persistence disabled if the dir cannot be created. + } +} + +interface PersistedError { + message: string; + firstSeen: number; +} + +function isPersistedError(value: unknown): value is PersistedError { + return ( + typeof value === 'object' && + value !== null && + 'message' in value && + typeof value.message === 'string' && + 'firstSeen' in value && + typeof value.firstSeen === 'number' + ); +} + +/** + * Read any existing `errors.jsonl` at `sinkPath`, seed each valid record into + * `ring`, then truncate the file. Best-effort: a missing or corrupt file is + * ignored, and individual unparseable lines are skipped. Truncation only runs + * when at least one line was read, so a missing file leaves nothing behind. + */ +export function seedErrorRingFromJsonl( + ring: ErrorRing, + sinkPath: string, +): void { + let raw: string; + try { + raw = readFileSync(sinkPath, 'utf-8'); + } catch { + return; // missing file (or unreadable): nothing to ship + } + + const lines = raw.split('\n').filter((line) => line.length > 0); + for (const line of lines) { + try { + const parsed: unknown = JSON.parse(line); + if (isPersistedError(parsed)) { + ring.seed(parsed.message, parsed.firstSeen); + } + } catch { + // Skip a corrupt line; keep seeding the rest. + } + } + + // Truncate so these errors are not re-shipped on the next boot. + try { + writeFileSync(sinkPath, ''); + } catch { + // Best-effort: if truncation fails the only cost is a re-ship next boot. + } +} diff --git a/packages/cli/src/commands/run/index.ts b/packages/cli/src/commands/run/index.ts index cf30f6648..a3111e8c6 100644 --- a/packages/cli/src/commands/run/index.ts +++ b/packages/cli/src/commands/run/index.ts @@ -10,6 +10,11 @@ import { writeFileSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { Level } from '@walkeros/core'; +import { + ensureSinkDir, + errorSinkPath, + seedErrorRingFromJsonl, +} from './error-sink.js'; import { createCLILogger, createCLILoggerConfig, @@ -148,6 +153,19 @@ export async function runCommand(options: RunCommandOptions): Promise { flowName, prepareBundleForRun: lazyPrepareBundleForRun, }; + + // Durable error persistence: only when a cacheDir exists (managed/API + // run). Wire the synchronous jsonl sink, then read-and-ship any errors + // that a prior boot persisted before a crash (seed into the ring so the + // first heartbeat re-reports them, then truncate). Best-effort throughout. + // + // Ensure the cache dir exists BEFORE wiring the sink, so the synchronous + // jsonl append can succeed on a fresh managed-runner container (where + // `writeCache` never runs to create it). + ensureSinkDir(apiConfig.cacheDir); + const sinkPath = errorSinkPath(apiConfig.cacheDir); + seedErrorRingFromJsonl(errorRing, sinkPath); + errorRing.setSink(sinkPath); } // Resolve bundle path diff --git a/packages/cli/src/commands/run/pipeline.ts b/packages/cli/src/commands/run/pipeline.ts index a2b8d3a8a..a3ca81605 100644 --- a/packages/cli/src/commands/run/pipeline.ts +++ b/packages/cli/src/commands/run/pipeline.ts @@ -23,7 +23,10 @@ import { resolveTelemetryOptions, } from '@walkeros/core'; import { getTmpPath } from '../../core/tmp.js'; -import { createHealthServer } from '../../runtime/health-server.js'; +import { + createHealthServer, + type HealthServer, +} from '../../runtime/health-server.js'; import { loadFlow, swapFlow, @@ -97,6 +100,14 @@ export async function runPipeline(options: PipelineOptions): Promise { let configVersion: string | undefined; const configFrozen = readConfigFrozen(); + // Process-level safety net FIRST, before any construction (secret injection, + // health server, flow load, openWriter). A stray init-window emit from a step + // or third-party lib (e.g. a gRPC StreamConnection's listener-less + // emit('error', ...) on a detached tick) must land in the net rather than + // crash the container before the guards are up. The degrade hook is wired in + // once the health server exists; the listeners stay registered from here. + const guards = registerProcessGuards(logger); + // Inject secrets before loading flow if (api) { await injectSecrets(api, logger); @@ -111,6 +122,29 @@ export async function runPipeline(options: PipelineOptions): Promise { // Health server (always on) const healthServer = await createHealthServer(port, logger); + // Wire the out-of-band degrade net into the already-registered guards. A + // sustained loop of uncaught exceptions / unhandled rejections (a wedged + // step or third-party lib) crosses the windowed threshold and flips `/ready` + // to 503 so the orchestrator recycles the container; a single stray error + // stays under the threshold and self-heals (stays 200). Degraded is a + // recycle signal, not a hot-swap-clearable state: only the boot-time + // `setReady(true)` below clears it, so a degraded container clears on a fresh + // boot after the orchestrator recycles it (a `swapFlow` does not call + // `setReady`, so an in-place hot-swap leaves degraded intact, as intended). + // The tracker keeps its own latch so it fires `setDegraded` once per + // crossing; that latch is independent of the health server's degraded flag. + const outOfBandTracker = createOutOfBandErrorTracker({ + threshold: OUT_OF_BAND_THRESHOLD, + windowMs: OUT_OF_BAND_WINDOW_MS, + onThresholdExceeded: () => { + logger.error( + `Out-of-band errors crossed ${OUT_OF_BAND_THRESHOLD} within ${OUT_OF_BAND_WINDOW_MS / 1000}s, degrading /ready for recycle`, + ); + healthServer.setDegraded('out-of-band error loop'); + }, + }); + guards.setOnOutOfBandError(() => outOfBandTracker.record()); + // Telemetry observers: only wire when observer URL, ingest token, and // deployment id are all present. Missing env (local dev, run --flow without // API) results in a no-op telemetry path. The active trace window arrives @@ -209,6 +243,16 @@ export async function runPipeline(options: PipelineOptions): Promise { ); heartbeat.start(); logger.info(`Heartbeat: active (every ${api.heartbeatIntervalMs / 1000}s)`); + + // Flush on first new distinct error: a fresh error key triggers a single + // debounced out-of-band beat so the failure surfaces well before the next + // steady interval (and before a crash can drop the in-memory ring). Repeats + // (isNew=false) do not flush, so a hot error loop does not spam POSTs. The + // ring↔heartbeat coupling lives here because this file owns both. + const beat = heartbeat; + options.errorRing?.setListener((_entry, isNew) => { + if (isNew) beat.flushSoon(); + }); } if (api && shouldStartPoller(api, configFrozen)) { @@ -302,54 +346,111 @@ export async function runPipeline(options: PipelineOptions): Promise { logger.info(`Polling: active (every ${api.pollIntervalMs / 1000}s)`); } - // Single shutdown orchestrator - const shutdown = async (signal: string) => { - logger.info(`Received ${signal}, shutting down...`); - - const forceTimer = setTimeout(() => { - logger.error('Shutdown timed out, forcing exit'); - process.exit(1); - }, 15000); - - try { - if (tracePoller) tracePoller.stop(); - if (poller) poller.stop(); - if (heartbeat) heartbeat.stop(); - if (handle.collector.command) { - await handle.collector.command('shutdown'); - } - await healthServer.close(); - - // Clean up temp files - if (currentBundleCleanup) await currentBundleCleanup().catch(() => {}); - if (currentConfigPath) await fs.remove(currentConfigPath).catch(() => {}); - - logger.info('Shutdown complete'); - clearTimeout(forceTimer); - process.exit(0); - } catch (error) { - clearTimeout(forceTimer); - logger.error( - `Error during shutdown: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); - } - }; + // Single shutdown orchestrator, driven through the extracted, dependency- + // injected `runShutdown` so the final-flush ordering is unit-testable. The + // current bundle/config temp-file references are read lazily inside the + // cleanup closure so a hot-swap that updates them before a shutdown removes + // the latest files, not the boot-time ones. + const shutdown = (signal: string) => + runShutdown(signal, { + tracePoller, + poller, + heartbeat, + collector: handle.collector, + healthServer, + logger, + exit: (code) => process.exit(code), + cleanupTempFiles: async () => { + if (currentBundleCleanup) await currentBundleCleanup().catch(() => {}); + if (currentConfigPath) + await fs.remove(currentConfigPath).catch(() => {}); + }, + forceTimeoutMs: 15000, + }); process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); - // Process-level safety net: a stray unhandled rejection or uncaught - // exception (e.g. from the dynamically imported bundle) must degrade, not - // crash the container. The guards log into the error ring via the logger and - // keep the process serving; the orchestrator's own /ready gate still governs - // traffic. Registration is idempotent in case runPipeline runs more than once. - registerProcessGuards(logger); - // Keep process alive await new Promise(() => {}); } +/** + * Dependencies the shutdown orchestrator needs, injectable so the final-flush + * ordering and force-timer behavior are unit-testable without a live pipeline. + * Mirrors the {@link ProcessGuardDeps} pattern. + */ +export interface ShutdownDeps { + tracePoller: TracePollerHandle | null; + poller: PollerHandle | null; + heartbeat: HeartbeatHandle | null; + collector: FlowHandle['collector']; + healthServer: Pick< + HealthServer, + 'setReady' | 'setFailed' | 'setDegraded' | 'close' + >; + logger: Logger.Instance; + exit: (code: number) => void; + /** Remove the latest bundle/config temp files (read lazily by the caller). */ + cleanupTempFiles: () => Promise; + /** Hard deadline (ms) after which shutdown forces exit(1). */ + forceTimeoutMs: number; +} + +/** + * Orchestrate a graceful shutdown. Order: + * tracePoller.stop → poller.stop → final heartbeat sendOnce (await, + * best-effort) → heartbeat.stop → collector shutdown → healthServer.close → + * temp-file cleanup → exit(0). + * + * The final `sendOnce` runs BEFORE collector drain so the last beat reflects the + * pre-drain counters/DLQ depth (what the operator wants at the moment of + * failure) and carries the final ring contents (incl. the error that may have + * triggered the shutdown). It is wrapped so a slow/rejecting POST cannot block + * shutdown; the whole sequence sits inside a force-timer so a hung step still + * exits at `forceTimeoutMs` (`sendOnce` has its own ~10s AbortSignal, under the + * 15s deadline). + */ +export async function runShutdown( + signal: string, + deps: ShutdownDeps, +): Promise { + deps.logger.info(`Received ${signal}, shutting down...`); + + const forceTimer = setTimeout(() => { + deps.logger.error('Shutdown timed out, forcing exit'); + deps.exit(1); + }, deps.forceTimeoutMs); + + try { + if (deps.tracePoller) deps.tracePoller.stop(); + if (deps.poller) deps.poller.stop(); + + // Final out-of-band beat: egress the last ring contents (incl. the failure + // that may have triggered this shutdown) before stopping/draining. Best- + // effort: a slow or rejecting POST must not wedge shutdown. + if (deps.heartbeat) await deps.heartbeat.sendOnce().catch(() => {}); + if (deps.heartbeat) deps.heartbeat.stop(); + + if (deps.collector.command) { + await deps.collector.command('shutdown'); + } + await deps.healthServer.close(); + + await deps.cleanupTempFiles(); + + deps.logger.info('Shutdown complete'); + clearTimeout(forceTimer); + deps.exit(0); + } catch (error) { + clearTimeout(forceTimer); + deps.logger.error( + `Error during shutdown: ${error instanceof Error ? error.message : String(error)}`, + ); + deps.exit(1); + } +} + /** * Dependencies the process guards need, injectable so the handler bodies are * unit-testable without registering real `process` listeners or exiting. @@ -357,22 +458,40 @@ export async function runPipeline(options: PipelineOptions): Promise { export interface ProcessGuardDeps { logger: Logger.Instance; exit: (code: number) => void; + /** + * Fed by BOTH guard handlers on every out-of-band error so a windowed + * counter can auto-degrade `/ready` after a sustained loop (and so a later + * task's collector `reportError` can route into the same counter). Optional + * because the handler bodies stay unit-testable without a real server. + */ + onOutOfBandError?: () => void; } /** * Handle an `unhandledRejection`: log the reason into the error ring (via the * logger) and keep serving. A stray rejection is treated as non-fatal — the * container degrades instead of crash-looping. + * + * This IS the registered `unhandledRejection` listener, so the body must never + * throw: if the logger or the degrade chain throws synchronously, the escape + * would itself become an uncaught error and crash the process — the exact + * failure this guard exists to prevent. The whole body is therefore wrapped; + * the fallback uses bare `console.error` (never a re-entrant `deps.logger`). */ export function handleUnhandledRejection( reason: unknown, deps: ProcessGuardDeps, ): void { - deps.logger.error( - `Unhandled rejection (continuing): ${ - reason instanceof Error ? reason.message : String(reason) - }`, - ); + try { + deps.logger.error( + `Unhandled rejection (continuing): ${ + reason instanceof Error ? reason.message : String(reason) + }`, + ); + deps.onOutOfBandError?.(); + } catch (guardError) { + console.error('Process guard handler failed (continuing):', guardError); + } } /** @@ -380,29 +499,75 @@ export function handleUnhandledRejection( * logger) and keep serving for non-fatal cases. `process.exit` is reserved for * genuinely unrecoverable state (handled by the shutdown orchestrator on * signals), not for a single stray throw. + * + * This IS the registered `uncaughtException` listener, so the body must never + * throw (see `handleUnhandledRejection`): a throw escaping here is fatal. The + * whole body is wrapped; the fallback uses bare `console.error`. */ export function handleUncaughtException( error: Error, deps: ProcessGuardDeps, ): void { - deps.logger.error(`Uncaught exception (continuing): ${error.message}`); + try { + deps.logger.error(`Uncaught exception (continuing): ${error.message}`); + deps.onOutOfBandError?.(); + } catch (guardError) { + console.error('Process guard handler failed (continuing):', guardError); + } +} + +/** + * Handle returned by {@link registerProcessGuards} so the caller can wire the + * `/ready` auto-degrade hook in AFTER the health server is constructed, while + * keeping the `uncaughtException`/`unhandledRejection` listeners registered + * from the very top of the pipeline. + */ +export interface ProcessGuardHandle { + /** Wire (or replace) the out-of-band error hook fed by both guard handlers. */ + setOnOutOfBandError(onOutOfBandError: () => void): void; } let processGuardsRegistered = false; +// Live deps shared by the registered listeners. Held at module scope so the +// idempotent re-registration path can still re-wire the degrade hook onto the +// already-mounted listeners (a fresh runPipeline gets a fresh health server). +let processGuardDeps: ProcessGuardDeps | undefined; + +const noopGuardHandle: ProcessGuardHandle = { + setOnOutOfBandError(onOutOfBandError) { + if (processGuardDeps) processGuardDeps.onOutOfBandError = onOutOfBandError; + }, +}; /** - * Register the process-level error guards exactly once per process. Guards - * against double-registration so a second `runPipeline` call in the same - * process does not stack listeners (which would multiply log lines). + * Register the process-level error guards exactly once per process, and return + * a handle to wire the out-of-band degrade hook later. Guards against + * double-registration so a second `runPipeline` call in the same process does + * not stack listeners (which would multiply log lines); the returned handle + * still re-points the live hook so the newest health server gets degraded. + * + * `onOutOfBandError` may be supplied up front, or wired later via the handle. + * The listeners read `deps.onOutOfBandError` live, so wiring it after the + * health server exists does not lose any error that arrived in between (it is + * just logged, not yet counted). */ -export function registerProcessGuards(logger: Logger.Instance): void { - if (processGuardsRegistered) return; +export function registerProcessGuards( + logger: Logger.Instance, + onOutOfBandError?: () => void, +): ProcessGuardHandle { + if (processGuardsRegistered) { + if (processGuardDeps && onOutOfBandError) + processGuardDeps.onOutOfBandError = onOutOfBandError; + return noopGuardHandle; + } processGuardsRegistered = true; const deps: ProcessGuardDeps = { logger, exit: (code) => process.exit(code), + onOutOfBandError, }; + processGuardDeps = deps; process.on('unhandledRejection', (reason) => handleUnhandledRejection(reason, deps), @@ -410,8 +575,86 @@ export function registerProcessGuards(logger: Logger.Instance): void { process.on('uncaughtException', (error) => handleUncaughtException(error, deps), ); + + return { + setOnOutOfBandError(next) { + deps.onOutOfBandError = next; + }, + }; +} + +/** + * Reset the one-shot registration latch so a test can register a fresh set of + * guard listeners. Tests must also `process.removeAllListeners(...)` the + * relevant events; this only clears the module-level idempotency flag. + */ +export function resetProcessGuardsForTest(): void { + processGuardsRegistered = false; + processGuardDeps = undefined; +} + +/** + * Configuration for the windowed out-of-band error counter that decides when + * `/ready` should auto-degrade. + */ +export interface OutOfBandErrorTrackerConfig { + /** Number of errors within `windowMs` that triggers a degrade. */ + threshold: number; + /** Rolling window length in milliseconds. */ + windowMs: number; + /** + * Called once when the count first crosses the threshold within the window. + * Not called again until the window drains below the threshold and re-crosses. + */ + onThresholdExceeded: () => void; + /** Injectable clock so the window is testable without fake timers. */ + now?: () => number; +} + +export interface OutOfBandErrorTracker { + /** Record one out-of-band error at the current `now()`. */ + record(): void; +} + +/** + * Windowed counter for out-of-band errors. A single stray error self-heals + * (stays under threshold, `/ready` keeps 200); a sustained hot loop crosses the + * threshold within the rolling window and triggers `onThresholdExceeded` + * exactly once per crossing, so the container is recycled rather than wedged + * behind a 200 with a half-open writer. + */ +export function createOutOfBandErrorTracker( + config: OutOfBandErrorTrackerConfig, +): OutOfBandErrorTracker { + const now = config.now ?? (() => Date.now()); + const timestamps: number[] = []; + let degraded = false; + + return { + record() { + const ts = now(); + timestamps.push(ts); + const cutoff = ts - config.windowMs; + while (timestamps.length > 0 && timestamps[0] < cutoff) + timestamps.shift(); + + if (timestamps.length >= config.threshold) { + if (!degraded) { + degraded = true; + config.onThresholdExceeded(); + } + } else { + degraded = false; + } + }, + }; } +/** N out-of-band errors within {@link OUT_OF_BAND_WINDOW_MS} degrade `/ready`. */ +const OUT_OF_BAND_THRESHOLD = 5; +/** Rolling window for the out-of-band degrade counter. */ +const OUT_OF_BAND_WINDOW_MS = 60_000; + /** * Resolve the poller's seed etag. The boot-time config fetch (Case 2 of the * run command) knows the etag and wins; the prebuilt-archive deploy path diff --git a/packages/cli/src/runtime/__tests__/heartbeat.test.ts b/packages/cli/src/runtime/__tests__/heartbeat.test.ts index fbbf52996..c1b733f79 100644 --- a/packages/cli/src/runtime/__tests__/heartbeat.test.ts +++ b/packages/cli/src/runtime/__tests__/heartbeat.test.ts @@ -54,6 +54,9 @@ interface SerializedDestination { /** The shape of the JSON body the heartbeat POSTs (fields under test). */ interface HeartbeatBody { instanceId?: string; + /** Configured heartbeat cadence in milliseconds. */ + intervalMs?: number; + uptime?: number; recentErrors?: SerializedRecord[]; recentLogs?: SerializedLog[]; counters?: { @@ -346,6 +349,8 @@ describe('heartbeat per-destination breakdown (dlqSize + dropped)', () => { sources: {}, destinations, dropped, + breakers: {}, + connectionErrors: {}, }; } @@ -391,6 +396,209 @@ describe('heartbeat per-destination breakdown (dlqSize + dropped)', () => { }); }); +describe('heartbeat flushSoon (debounced out-of-band beat)', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + globalThis.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + function typedLogger(): Logger.Instance { + const logger: Logger.Instance = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + throw: (message: string | Error): never => { + throw new Error( + typeof message === 'string' ? message : message.message, + ); + }, + json: jest.fn(), + scope: (_name: string): Logger.Instance => logger, + }; + return logger; + } + + it('coalesces a burst of N notifications within the debounce window into exactly one extra send', async () => { + const fetchMock = createFetchMock(); + globalThis.fetch = fetchMock; + + const heartbeat = createHeartbeat( + { + appUrl: 'http://localhost:3000', + token: 'bearer-test', + projectId: 'proj_1', + intervalMs: 60000, + flushDebounceMs: 1000, + }, + typedLogger(), + ); + + // Five new-error notifications inside the debounce window. + heartbeat.flushSoon(); + heartbeat.flushSoon(); + heartbeat.flushSoon(); + heartbeat.flushSoon(); + heartbeat.flushSoon(); + + // Nothing fires before the debounce elapses. + expect(fetchMock.mock.calls).toHaveLength(0); + + await jest.advanceTimersByTimeAsync(1000); + + // Exactly ONE extra POST for the whole burst. + expect(fetchMock.mock.calls).toHaveLength(1); + }); + + it('does not start or reset the steady interval timer', async () => { + const fetchMock = createFetchMock(); + globalThis.fetch = fetchMock; + + const heartbeat = createHeartbeat( + { + appUrl: 'http://localhost:3000', + token: 'bearer-test', + projectId: 'proj_1', + intervalMs: 60000, + flushDebounceMs: 1000, + }, + typedLogger(), + ); + + // flushSoon without start(): the steady interval is never created, so only + // the debounced flush fires — no interval beat appears afterward. + heartbeat.flushSoon(); + await jest.advanceTimersByTimeAsync(1000); + expect(fetchMock.mock.calls).toHaveLength(1); + + // Advancing well past one interval produces no further sends because the + // interval was never started by flushSoon. + await jest.advanceTimersByTimeAsync(120000); + expect(fetchMock.mock.calls).toHaveLength(1); + }); +}); + +describe('heartbeat intervalMs (advertised cadence)', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + function typedLogger(): Logger.Instance { + const logger: Logger.Instance = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + throw: (message: string | Error): never => { + throw new Error( + typeof message === 'string' ? message : message.message, + ); + }, + json: jest.fn(), + scope: (_name: string): Logger.Instance => logger, + }; + return logger; + } + + it('includes the configured intervalMs (in ms) in the body', async () => { + const fetchMock = createFetchMock(); + globalThis.fetch = fetchMock; + + const heartbeat = createHeartbeat( + { + appUrl: 'http://localhost:3000', + token: 'bearer-test', + projectId: 'proj_1', + intervalMs: 60000, + }, + typedLogger(), + ); + + await heartbeat.sendOnce(); + + const body = readHeartbeatBody(fetchMock); + expect(body.intervalMs).toBe(60000); + }); +}); + +describe('heartbeat uptime (restart evidence)', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + globalThis.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + function typedLogger(): Logger.Instance { + const logger: Logger.Instance = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + throw: (message: string | Error): never => { + throw new Error( + typeof message === 'string' ? message : message.message, + ); + }, + json: jest.fn(), + scope: (_name: string): Logger.Instance => logger, + }; + return logger; + } + + it('increases uptime across two beats within one process', async () => { + const fetchMock = createFetchMock(); + globalThis.fetch = fetchMock; + + const heartbeat = createHeartbeat( + { + appUrl: 'http://localhost:3000', + token: 'bearer-test', + projectId: 'proj_1', + intervalMs: 60000, + }, + typedLogger(), + ); + + await heartbeat.sendOnce(); + const firstInit = fetchMock.mock.calls[0]?.[1]; + const firstBodyRaw = firstInit?.body; + if (typeof firstBodyRaw !== 'string') { + throw new Error('expected a string request body'); + } + const firstBody: HeartbeatBody = JSON.parse(firstBodyRaw); + + await jest.advanceTimersByTimeAsync(5000); + + await heartbeat.sendOnce(); + const secondInit = fetchMock.mock.calls[1]?.[1]; + const secondBodyRaw = secondInit?.body; + if (typeof secondBodyRaw !== 'string') { + throw new Error('expected a string request body'); + } + const secondBody: HeartbeatBody = JSON.parse(secondBodyRaw); + + expect(firstBody.uptime).toBeDefined(); + expect(secondBody.uptime).toBeDefined(); + expect(secondBody.uptime ?? 0).toBeGreaterThan(firstBody.uptime ?? 0); + }); +}); + describe('computeCounterDelta', () => { it('computes correct delta for top-level counters', () => { const current: CounterSnapshot = { diff --git a/packages/cli/src/runtime/__tests__/log-ring.test.ts b/packages/cli/src/runtime/__tests__/log-ring.test.ts index cc4156da5..9d1cac215 100644 --- a/packages/cli/src/runtime/__tests__/log-ring.test.ts +++ b/packages/cli/src/runtime/__tests__/log-ring.test.ts @@ -1,5 +1,8 @@ +import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { LogRing, ErrorRing } from '../log-ring.js'; -import type { RingEntry } from '../log-ring.js'; +import type { RingEntry, DedupedError } from '../log-ring.js'; function makeEntry(time: number, message: string): RingEntry { return { time, level: 'info', message }; @@ -98,3 +101,135 @@ describe('ErrorRing', () => { expect(snap[2].message).toBe('first'); }); }); + +describe('ErrorRing listener', () => { + it('invokes the listener with isNew=true on the first occurrence and isNew=false on repeats', () => { + let t = 0; + const ring = new ErrorRing(10, () => t++); + const calls: Array<{ message: string; isNew: boolean }> = []; + ring.setListener((entry: DedupedError, isNew: boolean) => { + calls.push({ message: entry.message, isNew }); + }); + + ring.add('boom'); + ring.add('boom'); + ring.add('other'); + + expect(calls).toEqual([ + { message: 'boom', isNew: true }, + { message: 'boom', isNew: false }, + { message: 'other', isNew: true }, + ]); + }); + + it('does not let a throwing listener break add (entry is still recorded)', () => { + let t = 0; + const ring = new ErrorRing(10, () => t++); + ring.setListener(() => { + throw new Error('listener blew up'); + }); + + expect(() => ring.add('boom')).not.toThrow(); + expect(ring.snapshot().map((e) => e.message)).toContain('boom'); + }); +}); + +describe('ErrorRing durable jsonl sink', () => { + function tempDir(): string { + return mkdtempSync(join(tmpdir(), 'walkeros-ring-')); + } + + it('synchronously appends one line per new message, not on repeats', () => { + const dir = tempDir(); + const sink = join(dir, 'errors.jsonl'); + let t = 100; + const ring = new ErrorRing(10, () => t); + ring.setSink(sink); + + t = 100; + ring.add('boom'); // new → append + t = 200; + ring.add('boom'); // repeat → no append + t = 300; + ring.add('crash'); // new → append + + const lines = readFileSync(sink, 'utf-8') + .split('\n') + .filter((l) => l.length > 0); + expect(lines).toHaveLength(2); + const first: { message: string; firstSeen: number } = JSON.parse(lines[0]); + const second: { message: string; firstSeen: number } = JSON.parse(lines[1]); + expect(first.message).toBe('boom'); + expect(first.firstSeen).toBe(100); + expect(second.message).toBe('crash'); + expect(second.firstSeen).toBe(300); + }); + + it('swallows an fs append failure but still records the entry', () => { + // Point the sink at a path whose parent does not exist so appendFileSync + // throws ENOENT; add must not throw and must still record the entry. + const dir = tempDir(); + const sink = join(dir, 'does-not-exist', 'errors.jsonl'); + let t = 0; + const ring = new ErrorRing(10, () => t++); + ring.setSink(sink); + + expect(() => ring.add('boom')).not.toThrow(); + expect(ring.snapshot().map((e) => e.message)).toContain('boom'); + expect(existsSync(sink)).toBe(false); + }); + + it('does not append when no sink is configured (sink disabled)', () => { + // No setSink call: add must work and write nothing anywhere. Exercised by + // the absence of a throw and a normal snapshot. + let t = 0; + const ring = new ErrorRing(10, () => t++); + expect(() => ring.add('boom')).not.toThrow(); + expect(ring.snapshot()).toHaveLength(1); + }); + + it('seed inserts an entry without re-appending to the sink', () => { + const dir = tempDir(); + const sink = join(dir, 'errors.jsonl'); + let t = 500; + const ring = new ErrorRing(10, () => t); + ring.setSink(sink); + + ring.seed('prior boom', 42); + + // seeded entry is visible in the snapshot... + const snap = ring.snapshot(); + expect(snap).toHaveLength(1); + expect(snap[0].message).toBe('prior boom'); + expect(snap[0].firstSeen).toBe(42); + + // ...but seed must NOT write to the sink (otherwise boot re-ship would + // re-persist the same line forever). + expect(existsSync(sink)).toBe(false); + }); + + it('seed treats an already-seeded message as a repeat (isNew=false, no extra line)', () => { + const dir = tempDir(); + const sink = join(dir, 'errors.jsonl'); + let t = 0; + const ring = new ErrorRing(10, () => t++); + ring.setSink(sink); + + // Pre-existing file content does not matter; seed first, then a live add of + // the same message must be a dedup (count increments), and must not write a + // duplicate first-occurrence line. + writeFileSync(sink, ''); + ring.seed('boom', 7); + ring.add('boom'); + + const snap = ring.snapshot(); + expect(snap).toHaveLength(1); + expect(snap[0].count).toBe(2); + + const lines = readFileSync(sink, 'utf-8') + .split('\n') + .filter((l) => l.length > 0); + // add('boom') saw the seeded key as existing → isNew=false → no append. + expect(lines).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/runtime/__tests__/runner.test.ts b/packages/cli/src/runtime/__tests__/runner.test.ts index df2e285fa..2cc55a8b7 100644 --- a/packages/cli/src/runtime/__tests__/runner.test.ts +++ b/packages/cli/src/runtime/__tests__/runner.test.ts @@ -30,6 +30,10 @@ function createFakeHealthServer(): { ready = false; failures.push(reason); }, + setDegraded(reason) { + ready = false; + failures.push(reason); + }, close: async () => {}, }; diff --git a/packages/cli/src/runtime/health-server.ts b/packages/cli/src/runtime/health-server.ts index 66b1e0635..cf0dff961 100644 --- a/packages/cli/src/runtime/health-server.ts +++ b/packages/cli/src/runtime/health-server.ts @@ -16,6 +16,17 @@ export interface HealthServer { * subsequent successful `setReady(true)`. */ setFailed(reason: string): void; + /** + * Degrade readiness from an out-of-band condition (e.g. a sustained loop of + * uncaught exceptions / unhandled rejections from a wedged step or + * third-party lib). `/ready` returns 503 with a `degraded` status so the + * orchestrator recycles the container instead of letting a half-open writer + * hot-loop behind a 200. Distinct from `setFailed` (construction failure) so + * the surfaced cause is honest. Cleared only by a subsequent `setReady(true)` + * (called at boot, not by a hot-swap), so in practice degraded clears when + * the orchestrator recycles the container and a fresh process boots. + */ + setDegraded(reason: string): void; close(): Promise; } @@ -30,6 +41,10 @@ export function createHealthServer( // on /ready, so this closes the pre-ready functional window at the source. let ready = false; let failureReason: string | null = null; + // Distinguishes an out-of-band degrade (sustained uncaught error loop) from + // a construction failure, so /ready surfaces the honest cause. Both yield + // 503; only the status string differs. + let degraded = false; const server = http.createServer((req, res) => { // Runner-owned health routes — always available @@ -41,7 +56,13 @@ export function createHealthServer( if (req.url === '/ready' && req.method === 'GET') { const code = ready ? 200 : 503; - const status = ready ? 'ready' : failureReason ? 'failed' : 'not_ready'; + const status = ready + ? 'ready' + : degraded + ? 'degraded' + : failureReason + ? 'failed' + : 'not_ready'; res.writeHead(code, { 'Content-Type': 'application/json' }); res.end( JSON.stringify( @@ -76,10 +97,19 @@ export function createHealthServer( }, setReady(value) { ready = value; - if (value) failureReason = null; + if (value) { + failureReason = null; + degraded = false; + } }, setFailed(reason) { ready = false; + degraded = false; + failureReason = reason; + }, + setDegraded(reason) { + ready = false; + degraded = true; failureReason = reason; }, close: () => diff --git a/packages/cli/src/runtime/heartbeat.ts b/packages/cli/src/runtime/heartbeat.ts index 5b1125eca..e51fc0411 100644 --- a/packages/cli/src/runtime/heartbeat.ts +++ b/packages/cli/src/runtime/heartbeat.ts @@ -107,6 +107,12 @@ export interface HeartbeatConfig { deploymentId?: string; configVersion?: string; intervalMs: number; + /** + * Debounce window (ms) for {@link HeartbeatHandle.flushSoon}. A burst of new + * errors inside the window coalesces into ONE extra out-of-band POST. Default + * 1500ms. + */ + flushDebounceMs?: number; getCounters?: () => Collector.Status | undefined; getErrors?: () => DedupedError[]; getLogs?: () => RingEntry[]; @@ -116,9 +122,24 @@ export interface HeartbeatHandle { start(): void; stop(): void; sendOnce(): Promise; + /** + * Schedule a single out-of-band `sendOnce()` on a short debounce. Coalesces a + * burst of calls within the window into ONE POST. Does NOT touch the steady + * interval timer (the regular cadence keeps running); this is an extra beat so + * a fresh distinct error egresses well before the next interval. + * + * ACCEPTED overlap: the flushed `sendOnce` and a steady-interval `sendOnce` + * can run concurrently and both read/advance the shared `lastReported`. This + * is harmless: the heartbeat endpoint is idempotent and the worst case is one + * re-sent counter delta. No locking is added for this rare window. + */ + flushSoon(): void; updateConfigVersion(version: string): void; } +/** Default debounce window for {@link HeartbeatHandle.flushSoon}. */ +const DEFAULT_FLUSH_DEBOUNCE_MS = 1500; + export function createHeartbeat( config: HeartbeatConfig, logger: Logger.Instance, @@ -170,6 +191,10 @@ export function createHeartbeat( }), configVersion, cliVersion: VERSION, + // Advertise the configured heartbeat cadence (milliseconds) so the + // app reads the real interval instead of hard-coding a default for + // staleness detection. + intervalMs: config.intervalMs, uptime: Math.floor((Date.now() - startTime) / 1000), ...(counters && { counters }), // Always send recentErrors (even []) so a heartbeat that no longer @@ -211,11 +236,28 @@ export function createHeartbeat( clearInterval(timer); timer = null; } + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + } + + // Out-of-band flush: a single pending debounce timer coalesces a burst of + // flushSoon() calls into one POST. Independent of the steady interval timer. + let flushTimer: ReturnType | null = null; + const flushDebounceMs = config.flushDebounceMs ?? DEFAULT_FLUSH_DEBOUNCE_MS; + + function flushSoon(): void { + if (flushTimer) return; // already scheduled; coalesce into the pending beat + flushTimer = setTimeout(() => { + flushTimer = null; + sendOnce(); + }, flushDebounceMs); } function updateConfigVersion(version: string): void { configVersion = version; } - return { start, stop, sendOnce, updateConfigVersion }; + return { start, stop, sendOnce, flushSoon, updateConfigVersion }; } diff --git a/packages/cli/src/runtime/log-ring.ts b/packages/cli/src/runtime/log-ring.ts index 112bd6b27..b4c2ce0ea 100644 --- a/packages/cli/src/runtime/log-ring.ts +++ b/packages/cli/src/runtime/log-ring.ts @@ -1,3 +1,5 @@ +import { appendFileSync } from 'fs'; + export interface RingEntry { time: number; level: 'error' | 'warn' | 'info' | 'debug'; @@ -39,20 +41,46 @@ export class LogRing { } } +/** Observer fired after every {@link ErrorRing.add}; `isNew` is true only on the + * first occurrence of a distinct message (a repeat is `isNew=false`). */ +export type ErrorRingListener = (entry: DedupedError, isNew: boolean) => void; + export class ErrorRing { private readonly map = new Map(); + private listener: ErrorRingListener | undefined; + private sinkPath: string | undefined; constructor( private readonly maxUnique: number, private readonly now: () => number = () => Date.now(), ) {} + /** + * Register an observer notified after each `add`. The call is wrapped so a + * throwing listener can never break `add` (mirrors the cli-logger `onLine` + * try/catch). Only one listener is held; a later call replaces the prior. + */ + setListener(listener: ErrorRingListener): void { + this.listener = listener; + } + + /** + * Enable durable jsonl persistence: each first-seen message is synchronously + * appended as one `{ message, firstSeen }` line to `path`. Synchronous append + * is deliberate so a first-seen error survives a hard crash that beats the + * async heartbeat flush. Without a sink, persistence is disabled (no-op). + */ + setSink(path: string): void { + this.sinkPath = path; + } + add(message: string): void { const ts = this.now(); const existing = this.map.get(message); if (existing) { existing.count += 1; existing.lastSeen = ts; + this.notify(existing, false); return; } if (this.map.size >= this.maxUnique) { @@ -66,10 +94,57 @@ export class ErrorRing { } if (oldestKey !== undefined) this.map.delete(oldestKey); } - this.map.set(message, { message, count: 1, firstSeen: ts, lastSeen: ts }); + const entry: DedupedError = { + message, + count: 1, + firstSeen: ts, + lastSeen: ts, + }; + this.map.set(message, entry); + this.persist(entry); + this.notify(entry, true); + } + + /** + * Insert a pre-existing error (read from the durable jsonl on boot) WITHOUT + * persisting it again or notifying the flush listener. An already-present + * message is a no-op so a later live `add` of it dedups (count increments) + * instead of re-shipping a duplicate first-occurrence. + */ + seed(message: string, firstSeen: number): void { + if (this.map.has(message)) return; + if (this.map.size >= this.maxUnique) return; + this.map.set(message, { + message, + count: 1, + firstSeen, + lastSeen: firstSeen, + }); } snapshot(): DedupedError[] { return [...this.map.values()].sort((a, b) => b.lastSeen - a.lastSeen); } + + private notify(entry: DedupedError, isNew: boolean): void { + if (!this.listener) return; + try { + this.listener(entry, isNew); + } catch { + // A throwing listener must never break `add`. + } + } + + private persist(entry: DedupedError): void { + if (!this.sinkPath) return; + try { + appendFileSync( + this.sinkPath, + JSON.stringify({ message: entry.message, firstSeen: entry.firstSeen }) + + '\n', + ); + } catch { + // Best-effort: a failed append must never break `add`. + } + } } diff --git a/packages/collector/src/__tests__/breaker.test.ts b/packages/collector/src/__tests__/breaker.test.ts new file mode 100644 index 000000000..5d6e9cd98 --- /dev/null +++ b/packages/collector/src/__tests__/breaker.test.ts @@ -0,0 +1,475 @@ +import type { Destination, WalkerOS } from '@walkeros/core'; +import { createEvent, stepId } from '@walkeros/core'; +import { collector } from '../collector'; +import { pushToDestinations } from '../destination'; +import { + __resetBreakerNow, + __setBreakerNow, + isBreakerProbePermitted, +} from '../breaker'; + +/** + * Step-general circuit breaker (keyed by `stepId()`). + * + * These tests drive the REAL push path (`pushToDestinations` against a real + * `collector({})`) — not the breaker helpers in isolation — so they prove the + * gate, the failure/success accounting, and the canonical-key resolution all + * agree. Time is controlled through the injectable breaker clock + * (`__setBreakerNow`) so open→half-open is deterministic without fake timers. + */ + +const BREAKER_KEY = stepId('destination', 'd1'); + +/** A real, push-allowed collector. */ +async function makeCollector() { + const c = await collector({}); + c.allowed = true; + return c; +} + +/** A destination whose push() outcome is controlled by `behavior()`. */ +function makeDestination( + behavior: () => 'ok' | 'throw', + config: Destination.Config, + push: jest.Mock, +): Destination.Instance { + return { + type: 'mock', + config, + push: push.mockImplementation((_event: WalkerOS.Event) => { + if (behavior() === 'throw') throw new Error('transport down'); + return { ok: true }; + }), + }; +} + +describe('circuit breaker', () => { + let now: number; + + beforeEach(() => { + now = 1_000_000; + __setBreakerNow(() => now); + }); + + afterEach(() => { + __resetBreakerNow(); + }); + + test('presence-gated: with no breaker config, events are never skipped', async () => { + const push = jest.fn(); + let outcome: 'ok' | 'throw' = 'throw'; + const destination = makeDestination(() => outcome, {}, push); + const c = await makeCollector(); + c.destinations.d1 = destination; + + for (let i = 0; i < 10; i++) { + await pushToDestinations(c, createEvent()); + } + + // Every event was pushed (and failed); no breaker entry exists. + expect(push).toHaveBeenCalledTimes(10); + expect(c.status.breakers[BREAKER_KEY]).toBeUndefined(); + }); + + test('N consecutive transport failures open the breaker; later events skip', async () => { + const push = jest.fn(); + const destination = makeDestination(() => 'throw', { breaker: 3 }, push); + const c = await makeCollector(); + c.destinations.d1 = destination; + + // 3 consecutive failures → opens on the 3rd. + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + + expect(push).toHaveBeenCalledTimes(3); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + expect(c.status.breakers[BREAKER_KEY].consecutiveFailures).toBe(3); + + // Subsequent events are skipped (counted, not pushed). + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + expect(push).toHaveBeenCalledTimes(3); + }); + + test('CONSECUTIVE not cumulative: fail, fail, success, fail does NOT open at threshold 3', async () => { + const push = jest.fn(); + let outcome: 'ok' | 'throw' = 'throw'; + const destination = makeDestination(() => outcome, { breaker: 3 }, push); + const c = await makeCollector(); + c.destinations.d1 = destination; + + outcome = 'throw'; + await pushToDestinations(c, createEvent()); // fail (1) + await pushToDestinations(c, createEvent()); // fail (2) + outcome = 'ok'; + await pushToDestinations(c, createEvent()); // success → reset + outcome = 'throw'; + await pushToDestinations(c, createEvent()); // fail (1) + + expect(c.status.breakers[BREAKER_KEY].state).toBe('closed'); + expect(c.status.breakers[BREAKER_KEY].consecutiveFailures).toBe(1); + // All four reached the destination (never skipped). + expect(push).toHaveBeenCalledTimes(4); + }); + + test('cooldown elapses → half-open admits exactly one probe; concurrent burst admits only one', async () => { + const push = jest.fn(); + let outcome: 'ok' | 'throw' = 'throw'; + const destination = makeDestination( + () => outcome, + { breaker: { threshold: 2, cooldown: 5000 } }, + push, + ); + const c = await makeCollector(); + c.destinations.d1 = destination; + + // Open the breaker (2 consecutive failures). + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + expect(push).toHaveBeenCalledTimes(2); + + // Within cooldown: skipped. + now += 1000; + await pushToDestinations(c, createEvent()); + expect(push).toHaveBeenCalledTimes(2); + + // Advance past cooldown, then fire a concurrent burst at the boundary. + now += 5000; + outcome = 'throw'; + await Promise.all([ + pushToDestinations(c, createEvent()), + pushToDestinations(c, createEvent()), + pushToDestinations(c, createEvent()), + ]); + // Exactly one probe admitted; the other two saw half-open and skipped. + expect(push).toHaveBeenCalledTimes(3); + // Probe failed → re-opened with a fresh window. + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + }); + + test('probe success closes the breaker', async () => { + const push = jest.fn(); + let outcome: 'ok' | 'throw' = 'throw'; + const destination = makeDestination( + () => outcome, + { breaker: { threshold: 2, cooldown: 5000 } }, + push, + ); + const c = await makeCollector(); + c.destinations.d1 = destination; + + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + + now += 6000; + outcome = 'ok'; + await pushToDestinations(c, createEvent()); // probe succeeds + expect(c.status.breakers[BREAKER_KEY].state).toBe('closed'); + expect(c.status.breakers[BREAKER_KEY].consecutiveFailures).toBe(0); + expect(push).toHaveBeenCalledTimes(3); + }); + + test('probe failure re-opens with a fresh window; no re-open storm within the open window', async () => { + const push = jest.fn(); + const destination = makeDestination( + () => 'throw', + { breaker: { threshold: 2, cooldown: 5000 } }, + push, + ); + const c = await makeCollector(); + c.destinations.d1 = destination; + + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + const firstOpenUntil = c.status.breakers[BREAKER_KEY].openUntil; + + now += 6000; + await pushToDestinations(c, createEvent()); // probe fails → re-open + const secondOpenUntil = c.status.breakers[BREAKER_KEY].openUntil; + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + expect(secondOpenUntil).toBeGreaterThan(firstOpenUntil ?? 0); + expect(push).toHaveBeenCalledTimes(3); + + // Within the new window: all skipped, no extra pushes (no storm). + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + expect(push).toHaveBeenCalledTimes(3); + }); + + test('canonical-key: config.id differs from map key, breaker still opens', async () => { + const push = jest.fn(); + const destination = makeDestination( + () => 'throw', + { id: 'real-id', breaker: 2 }, + push, + ); + const c = await makeCollector(); + // Register under a DIFFERENT map key than config.id. + c.destinations.mapKey = destination; + + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + + const canonicalKey = stepId('destination', 'real-id'); + expect(c.status.breakers[canonicalKey].state).toBe('open'); + // No breaker entry under the map key — proves gate + accounting agree. + expect(c.status.breakers[stepId('destination', 'mapKey')]).toBeUndefined(); + + // Next event is skipped (gate reads the canonical entry). + await pushToDestinations(c, createEvent()); + expect(push).toHaveBeenCalledTimes(2); + }); + + describe('batch path', () => { + test('a whole-batch throw trips the breaker', async () => { + const pushBatch = jest.fn(() => { + throw new Error('batch transport down'); + }); + const destination: Destination.Instance = { + type: 'mock', + push: jest.fn(), + pushBatch, + config: { + breaker: 1, + // 1-minute debounce; we flush manually for determinism. + batch: { wait: 60_000 }, + }, + }; + const c = await makeCollector(); + c.destinations.d1 = destination; + + await pushToDestinations(c, createEvent()); + // Force the flush (no timer advance). + await c.destinations.d1.batches![' batch-all'].flush(); + + expect(pushBatch).toHaveBeenCalledTimes(1); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + }); + + test('partial-failure rows are breaker-neutral (do not trip a healthy destination)', async () => { + // Resolve a BatchOutcome listing one failed row per flush. With threshold + // 1 a cumulative counter would open after the first poison row; a + // breaker-neutral partial keeps it closed. + const pushBatch = jest.fn( + (): Destination.BatchOutcome => ({ failed: [{ index: 0 }] }), + ); + const destination: Destination.Instance = { + type: 'mock', + push: jest.fn(), + pushBatch, + config: { + breaker: 1, + batch: { wait: 60_000 }, + }, + }; + const c = await makeCollector(); + c.destinations.d1 = destination; + + // Two events so at least one row is delivered (index 1 succeeds). + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + await c.destinations.d1.batches![' batch-all'].flush(); + + // Delivered rows → success closes/keeps closed; partial row never trips. + expect(c.status.breakers[BREAKER_KEY].state).toBe('closed'); + expect(c.status.breakers[BREAKER_KEY].consecutiveFailures).toBe(0); + }); + }); + + describe('probe never deadlocks half-open', () => { + test('init throws on the probe → re-opens (transport-failure) and still recovers later', async () => { + const push = jest.fn().mockReturnValue({ ok: true }); + let initShouldThrow = true; + const init = jest.fn(() => { + if (initShouldThrow) throw new Error('init transport down'); + return undefined; + }); + const destination: Destination.Instance = { + type: 'mock', + init, + push, + config: { breaker: { threshold: 2, cooldown: 5000 } }, + }; + const c = await makeCollector(); + c.destinations.d1 = destination; + + // Two failing inits open the breaker (each throw is a transport failure). + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + + // Cooldown elapses → probe admitted, but its init throws again. The probe + // MUST settle (re-open), not leave probing stuck. + now += 6000; + await pushToDestinations(c, createEvent()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + expect(c.status.breakers[BREAKER_KEY].probing).toBe(false); + + // Transport heals; after another cooldown the breaker probes again and + // closes — proving it was NOT stuck half-open. + initShouldThrow = false; + now += 6000; + await pushToDestinations(c, createEvent()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('closed'); + expect(push).toHaveBeenCalled(); + }); + + test('consent-denied probe → released (not counted as a failure) and recovers later', async () => { + const push = jest.fn(); + let outcome: 'ok' | 'throw' = 'throw'; + const destination = makeDestination( + () => outcome, + { + breaker: { threshold: 2, cooldown: 5000 }, + consent: { marketing: true }, + }, + push, + ); + const c = await makeCollector(); + c.consent = { marketing: true }; + c.destinations.d1 = destination; + + // Two consent-granted failing pushes open the breaker. + const granted = () => { + const e = createEvent(); + e.consent = { marketing: true }; + return e; + }; + await pushToDestinations(c, granted()); + await pushToDestinations(c, granted()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + expect(c.status.breakers[BREAKER_KEY].consecutiveFailures).toBe(2); + const pushesAfterOpen = push.mock.calls.length; + + // Cooldown elapses, but the probe event is consent-denied: it never + // reaches the transport, so it must RELEASE the probe (no failure count), + // not deadlock half-open. + now += 6000; + c.consent = {}; + const denied = createEvent(); + denied.consent = {}; + await pushToDestinations(c, denied); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + expect(c.status.breakers[BREAKER_KEY].probing).toBe(false); + // The consent skip did NOT count as a transport failure. + expect(c.status.breakers[BREAKER_KEY].consecutiveFailures).toBe(2); + expect(push.mock.calls.length).toBe(pushesAfterOpen); + + // Healthy + consent-granted event after a fresh cooldown → probe closes. + outcome = 'ok'; + c.consent = { marketing: true }; + now += 6000; + await pushToDestinations(c, granted()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('closed'); + }); + + test('full recovery cycle: open → probe-fail → probe-succeed → closed', async () => { + const push = jest.fn(); + let outcome: 'ok' | 'throw' = 'throw'; + const destination = makeDestination( + () => outcome, + { breaker: { threshold: 2, cooldown: 5000 } }, + push, + ); + const c = await makeCollector(); + c.destinations.d1 = destination; + + // Open. + await pushToDestinations(c, createEvent()); + await pushToDestinations(c, createEvent()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + + // Probe fails → re-open. + now += 6000; + await pushToDestinations(c, createEvent()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + + // Within the new window: skipped (no storm). + const callsBefore = push.mock.calls.length; + await pushToDestinations(c, createEvent()); + expect(push.mock.calls.length).toBe(callsBefore); + + // Probe succeeds after the next cooldown → closed. + now += 6000; + outcome = 'ok'; + await pushToDestinations(c, createEvent()); + expect(c.status.breakers[BREAKER_KEY].state).toBe('closed'); + expect(c.status.breakers[BREAKER_KEY].consecutiveFailures).toBe(0); + }); + + test('probe settles through the BATCH FLUSH path (throw re-opens, success closes)', async () => { + let batchShouldThrow = true; + const pushBatch = jest.fn((): Destination.BatchOutcome | void => { + if (batchShouldThrow) throw new Error('batch transport down'); + }); + const destination: Destination.Instance = { + type: 'mock', + push: jest.fn(), + pushBatch, + config: { + breaker: { threshold: 2, cooldown: 5000 }, + batch: { wait: 60_000 }, + }, + }; + const c = await makeCollector(); + c.destinations.d1 = destination; + + const flush = () => c.destinations.d1.batches![' batch-all'].flush(); + + // Open via two whole-batch throws. + await pushToDestinations(c, createEvent()); + await flush(); + await pushToDestinations(c, createEvent()); + await flush(); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + + // Cooldown → probe admitted, enqueued, then flush THROWS: the flush path + // must settle the probe (re-open), not leave it stuck. + now += 6000; + await pushToDestinations(c, createEvent()); + await flush(); + expect(c.status.breakers[BREAKER_KEY].state).toBe('open'); + expect(c.status.breakers[BREAKER_KEY].probing).toBe(false); + + // Cooldown → probe admitted, flush SUCCEEDS: the flush path closes it. + batchShouldThrow = false; + now += 6000; + await pushToDestinations(c, createEvent()); + await flush(); + expect(c.status.breakers[BREAKER_KEY].state).toBe('closed'); + }); + }); + + describe('isBreakerProbePermitted', () => { + test('permits a probe when closed or half-open, blocks while open within cooldown', () => { + expect(isBreakerProbePermitted(undefined, now)).toBe(true); + expect( + isBreakerProbePermitted( + { state: 'closed', consecutiveFailures: 0 }, + now, + ), + ).toBe(true); + expect( + isBreakerProbePermitted( + { state: 'half-open', consecutiveFailures: 3, probing: true }, + now, + ), + ).toBe(true); + expect( + isBreakerProbePermitted( + { state: 'open', consecutiveFailures: 3, openUntil: now + 1000 }, + now, + ), + ).toBe(false); + expect( + isBreakerProbePermitted( + { state: 'open', consecutiveFailures: 3, openUntil: now - 1 }, + now, + ), + ).toBe(true); + }); + }); +}); diff --git a/packages/collector/src/__tests__/observerEmit.test.ts b/packages/collector/src/__tests__/observerEmit.test.ts index 21913b0ba..f6ee1e2ac 100644 --- a/packages/collector/src/__tests__/observerEmit.test.ts +++ b/packages/collector/src/__tests__/observerEmit.test.ts @@ -36,6 +36,8 @@ function makeCollector(observers: Set): Collector.Instance { sources: {}, destinations: {}, dropped: {}, + connectionErrors: {}, + breakers: {}, }, timing: 1000, user: {}, diff --git a/packages/collector/src/__tests__/queue-bounds.test.ts b/packages/collector/src/__tests__/queue-bounds.test.ts index 1691a13c0..73e950e77 100644 --- a/packages/collector/src/__tests__/queue-bounds.test.ts +++ b/packages/collector/src/__tests__/queue-bounds.test.ts @@ -62,6 +62,8 @@ describe('queue bounds', () => { sources: {}, destinations: {}, dropped: {}, + connectionErrors: {}, + breakers: {}, }, config, push: jest.fn(), diff --git a/packages/collector/src/__tests__/report-error.test.ts b/packages/collector/src/__tests__/report-error.test.ts new file mode 100644 index 000000000..a2ad35e43 --- /dev/null +++ b/packages/collector/src/__tests__/report-error.test.ts @@ -0,0 +1,191 @@ +import type { Destination, Source, WalkerOS } from '@walkeros/core'; +import { createEvent, createMockLogger, stepId } from '@walkeros/core'; +import { collector } from '../collector'; +import { buildReportError } from '../report-error'; + +/** + * Step-general out-of-band error contract (`reportError`). + * + * `reportError` is built into the context of EVERY step kind (source, + * transformer, store, destination) by the collector. A step that owns an + * EventEmitter SDK object calls it from the object's `'error'` handler, where + * there is no surrounding `await`/`tryCatchAsync`. It MUST be contained + * (never throws), MUST be a stable closure (a long-lived connection holds the + * same reference for its lifetime), and MUST distinguish orphan errors + * (connection-level, counted under `connectionErrors`) from event-bearing + * failures (DLQ + `failed`). + * + * Tests run against a REAL collector (`collector({})`) so `status` is the + * genuine runtime object, while a `createMockLogger()` is passed as the + * `logger` argument to `buildReportError` so logging is observable. The two + * are independent: `buildReportError` takes the logger as a separate + * parameter, it does not read it off the collector. + */ + +function makeDestination(dlq: Destination.DLQ): Destination.Instance { + return { config: { id: 'bigquery' }, push: jest.fn(), dlq }; +} + +describe('reportError', () => { + let event: WalkerOS.Event; + + beforeEach(() => { + event = createEvent(); + }); + + describe('orphan form (no event)', () => { + test('bumps connectionErrors[stepId] and does NOT touch failed', async () => { + const c = await collector({}); + const logger = createMockLogger(); + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + ); + + reportError(new Error('stream broken')); + + const key = stepId('destination', 'bigquery'); + expect(c.status.connectionErrors[key]).toBe(1); + expect(c.status.failed).toBe(0); + expect(logger.error).toHaveBeenCalled(); + }); + + test('accumulates connectionErrors across repeated orphan reports', async () => { + const c = await collector({}); + const logger = createMockLogger(); + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + ); + + reportError(new Error('a')); + reportError(new Error('b')); + reportError(new Error('c')); + + expect(c.status.connectionErrors[stepId('destination', 'bigquery')]).toBe( + 3, + ); + expect(c.status.failed).toBe(0); + }); + + test('does not throw when the scoped logger.error throws', async () => { + const c = await collector({}); + const logger = createMockLogger(); + logger.error.mockImplementation(() => { + throw new Error('logger exploded'); + }); + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + ); + + expect(() => reportError(new Error('stream broken'))).not.toThrow(); + }); + }); + + describe('event-bearing form', () => { + test('routes the event to the destination DLQ and bumps failed exactly once', async () => { + const dlq: Destination.DLQ = []; + const destination = makeDestination(dlq); + const c = await collector({}); + c.destinations.bigquery = destination; + const logger = createMockLogger(); + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + destination, + ); + + const err = new Error('insert failed'); + reportError(err, event); + + expect(dlq).toHaveLength(1); + expect(dlq[0][0]).toBe(event); + expect(dlq[0][1]).toBe(err); + expect(c.status.failed).toBe(1); + expect(c.status.destinations.bigquery.failed).toBe(1); + // The event-bearing form must NOT also bump connectionErrors. + expect( + c.status.connectionErrors[stepId('destination', 'bigquery')], + ).toBeUndefined(); + }); + + test('does not throw when DLQ routing internals fail', async () => { + const c = await collector({}); + const logger = createMockLogger(); + // No destination instance passed: the with-event path has nowhere to + // DLQ but must still be contained and still account the failure. + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + ); + + expect(() => reportError(new Error('x'), event)).not.toThrow(); + expect(c.status.failed).toBe(1); + }); + }); + + describe('stable closure', () => { + test('the captured reference stays valid across multiple uses', async () => { + const dlq: Destination.DLQ = []; + const destination = makeDestination(dlq); + const c = await collector({}); + c.destinations.bigquery = destination; + const logger = createMockLogger(); + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + destination, + ); + + // orphan, then event-bearing, then orphan again — same reference. + reportError(new Error('flap 1')); + reportError(new Error('lost'), event); + reportError(new Error('flap 2')); + + const key = stepId('destination', 'bigquery'); + expect(c.status.connectionErrors[key]).toBe(2); + expect(c.status.failed).toBe(1); + expect(dlq).toHaveLength(1); + }); + }); + + describe('step-general (not destination-only)', () => { + test('orphan reportError works for a non-destination (source) step', async () => { + const c = await collector({}); + const logger = createMockLogger(); + const reportError = buildReportError(c, 'source', 'express', logger); + + reportError(new Error('listener died')); + + expect(c.status.connectionErrors[stepId('source', 'express')]).toBe(1); + expect(c.status.failed).toBe(0); + }); + }); +}); + +/** + * Proves the contract is actually present on the context objects the + * collector hands to each step kind, not just on the destination path. A + * type-level presence check: assigning the field guarantees `Base.reportError` + * exists on every step Context (compile-time). The runtime assertion confirms + * a built context exposes a callable `reportError`. + */ +describe('reportError is present on every step context', () => { + test('Source.Context carries reportError (step-general)', () => { + const reportError: Source.Context['reportError'] = () => undefined; + expect(typeof reportError).toBe('function'); + }); +}); diff --git a/packages/collector/src/__tests__/store-cache-wrapper.observer.test.ts b/packages/collector/src/__tests__/store-cache-wrapper.observer.test.ts index de8424187..84d12c0a7 100644 --- a/packages/collector/src/__tests__/store-cache-wrapper.observer.test.ts +++ b/packages/collector/src/__tests__/store-cache-wrapper.observer.test.ts @@ -38,6 +38,8 @@ function createTestCollector(): Collector.Instance { sources: {}, destinations: {}, dropped: {}, + connectionErrors: {}, + breakers: {}, }, timing: 0, user: {}, diff --git a/packages/collector/src/__tests__/store.test.ts b/packages/collector/src/__tests__/store.test.ts index 04b199eb0..3c12f2ce6 100644 --- a/packages/collector/src/__tests__/store.test.ts +++ b/packages/collector/src/__tests__/store.test.ts @@ -49,6 +49,8 @@ function createMockCollector(): Collector.Instance { sources: {}, destinations: {}, dropped: {}, + connectionErrors: {}, + breakers: {}, }, timing: 0, user: {}, diff --git a/packages/collector/src/__tests__/transformer-branch.test.ts b/packages/collector/src/__tests__/transformer-branch.test.ts index d73c598c5..9a7ffe22b 100644 --- a/packages/collector/src/__tests__/transformer-branch.test.ts +++ b/packages/collector/src/__tests__/transformer-branch.test.ts @@ -70,6 +70,8 @@ function createMockCollector( sources: {}, destinations: {}, dropped: {}, + connectionErrors: {}, + breakers: {}, }, timing: 0, user: {}, diff --git a/packages/collector/src/breaker.ts b/packages/collector/src/breaker.ts new file mode 100644 index 000000000..1370cc82d --- /dev/null +++ b/packages/collector/src/breaker.ts @@ -0,0 +1,202 @@ +import type { BreakerState, Destination } from '@walkeros/core'; + +/** + * Step-general circuit breaker. + * + * State lives in `collector.status.breakers`, keyed by `stepId()` so the + * breaker is step-agnostic (destinations are the primary use today). All sites + * — the skip gate, the per-event failure/success accounting, the batch flush — + * drive their state changes through this one module so the gate and the + * accounting can never disagree about a step's health. + * + * The breaker is presence-gated: a step with no `breaker` config never trips + * (the caller skips this module entirely when {@link resolveBreakerConfig} + * returns `undefined`). + * + * Time is read through an injectable `now()` so open→half-open transitions are + * deterministic in tests without fake timers (mirrors `ErrorRing` in the cli). + */ + +export const DEFAULT_BREAKER_THRESHOLD = 5; +export const DEFAULT_BREAKER_COOLDOWN_MS = 30_000; + +/** Resolved, presence-checked breaker tuning for one step. */ +export interface BreakerConfig { + threshold: number; + cooldown: number; +} + +/** + * The kind of transport outcome a step site reports. `transport-failure` + * counts toward opening; `success` resets and closes; `partial` is a + * deliberate no-op (row-level batch failures must not trip a healthy + * destination). + */ +export type StepOutcome = 'transport-failure' | 'success' | 'partial'; + +/** + * Injectable clock. Default reads wall time; tests swap it via + * {@link __setBreakerNow} so open→half-open is deterministic. + */ +let nowFn: () => number = () => Date.now(); + +/** Test-only: override the breaker clock. */ +export function __setBreakerNow(fn: () => number): void { + nowFn = fn; +} + +/** Test-only: restore the real-time breaker clock. */ +export function __resetBreakerNow(): void { + nowFn = () => Date.now(); +} + +/** Current breaker time (injectable). */ +export function breakerNow(): number { + return nowFn(); +} + +/** + * Resolve a destination's `breaker` config into tuned values, or `undefined` + * when no breaker is configured (presence-gating: the breaker stays inert). + * A bare number is the threshold. + */ +export function resolveBreakerConfig( + breaker: Destination.Config['breaker'], +): BreakerConfig | undefined { + if (breaker === undefined) return undefined; + if (typeof breaker === 'number') { + return { threshold: breaker, cooldown: DEFAULT_BREAKER_COOLDOWN_MS }; + } + return { + threshold: breaker.threshold ?? DEFAULT_BREAKER_THRESHOLD, + cooldown: breaker.cooldown ?? DEFAULT_BREAKER_COOLDOWN_MS, + }; +} + +/** Lazily create and return the breaker state for a step. */ +export function ensureBreakerState( + breakers: Record, + key: string, +): BreakerState { + if (!breakers[key]) { + breakers[key] = { state: 'closed', consecutiveFailures: 0 }; + } + return breakers[key]; +} + +/** + * The skip gate. Returns true when the step should be skipped (breaker open + * and no probe slot available for this caller). + * + * On the open→half-open boundary the transition is atomic within this + * synchronous call: the FIRST event at/after `openUntil` claims the probe + * (sets `state='half-open'`, `probing=true`, re-arms `openUntil`) and is + * admitted; every concurrent event then sees half-open with the probe taken + * and skips. Single-threaded JS guarantees this mutate-before-return is not + * interleaved with another gate call. + */ +export function isBreakerOpen( + breakers: Record, + key: string, + cooldown: number, +): boolean { + const breaker = breakers[key]; + if (!breaker || breaker.state === 'closed') return false; + + if (breaker.state === 'half-open') { + // Probe slot already claimed by an earlier event in this window. + return breaker.probing === true; + } + + // state === 'open' + const now = nowFn(); + if (breaker.openUntil !== undefined && now < breaker.openUntil) { + return true; // still cooling down + } + + // Cooldown elapsed: this caller becomes the single probe. Re-arm the window + // and take the probe slot so concurrent callers still skip. + breaker.state = 'half-open'; + breaker.probing = true; + breaker.openUntil = now + cooldown; + return false; +} + +/** + * Record a transport outcome for a step and apply the resulting state + * transition. The single accounting authority for all sites. + */ +export function recordStepOutcome( + breakers: Record, + key: string, + outcome: StepOutcome, + threshold: number, + cooldown: number, +): void { + if (outcome === 'partial') return; // breaker-neutral + + const breaker = ensureBreakerState(breakers, key); + + if (outcome === 'success') { + breaker.consecutiveFailures = 0; + breaker.state = 'closed'; + breaker.probing = false; + breaker.openUntil = undefined; + return; + } + + // transport-failure + breaker.consecutiveFailures += 1; + + if (breaker.state === 'half-open') { + // The probe failed: re-open with a fresh cooldown window. + breaker.state = 'open'; + breaker.probing = false; + breaker.openUntil = nowFn() + cooldown; + return; + } + + if (breaker.consecutiveFailures >= threshold) { + breaker.state = 'open'; + breaker.probing = false; + breaker.openUntil = nowFn() + cooldown; + } +} + +/** + * Release a half-open probe slot WITHOUT recording an outcome. Called when an + * admitted probe event settles on a path that never exercised the transport + * (consent-denied, empty queue, queueOn-only, re-queued). The probe never + * tested transport health, so it must not count as a failure or a success; + * instead the breaker reverts half-open → open, keeping `consecutiveFailures` + * intact, so the next event at/after the (already re-armed) `openUntil` can + * probe again. Without this, an admitted probe that never pushes would leave + * `probing=true` forever and the breaker would deadlock half-open. + * + * No-op unless the breaker is currently half-open with a probe in flight. + */ +export function releaseProbe( + breakers: Record, + key: string, +): void { + const breaker = breakers[key]; + if (!breaker || breaker.state !== 'half-open' || breaker.probing !== true) { + return; + } + breaker.state = 'open'; + breaker.probing = false; +} + +/** + * Predicate exposed for the BigQuery self-heal re-open (Task 4): true when a + * probe would be permitted at `now` — the breaker is closed, half-open, or + * open with its cooldown elapsed. Task 4 gates its re-open on this. + */ +export function isBreakerProbePermitted( + breaker: BreakerState | undefined, + now: number, +): boolean { + if (!breaker) return true; + if (breaker.state === 'closed' || breaker.state === 'half-open') return true; + return breaker.openUntil !== undefined && now >= breaker.openUntil; +} diff --git a/packages/collector/src/collector.ts b/packages/collector/src/collector.ts index a78db84c1..578a19dc6 100644 --- a/packages/collector/src/collector.ts +++ b/packages/collector/src/collector.ts @@ -61,6 +61,8 @@ export async function collector( sources: {}, destinations: {}, dropped: {}, + connectionErrors: {}, + breakers: {}, }, timing: Date.now(), user: initConfig.user || {}, diff --git a/packages/collector/src/destination.ts b/packages/collector/src/destination.ts index 5757e39dd..fceead997 100644 --- a/packages/collector/src/destination.ts +++ b/packages/collector/src/destination.ts @@ -40,10 +40,21 @@ import { } from './transformer'; import { getCacheStore, getStateStore } from './cache'; import { pushBounded, resetOverflowFlag, warnOverflowOnce } from './buffers'; +import { + DEFAULT_DLQ_MAX, + bumpDropped, + ensureDestStatus, + buildReportError, +} from './report-error'; import { reconcilePending } from './pending'; +import { + isBreakerOpen, + recordStepOutcome, + releaseProbe, + resolveBreakerConfig, +} from './breaker'; const DEFAULT_QUEUE_MAX = 1_000; -const DEFAULT_DLQ_MAX = 100; /** Default upper-bound on entries per batch. Caps unbounded growth under sustained load. */ const DEFAULT_BATCH_SIZE = 1_000; /** Default upper-bound on batch age in ms. Forces flush even if debounce keeps resetting. */ @@ -147,43 +158,6 @@ function normalizeBatchOptions( return { wait: value.wait, size: value.size, age: value.age }; } -/** - * Ensure a per-destination status entry exists and return it. - * Mirrors the shape used elsewhere in this file. - */ -function ensureDestStatus( - collector: Collector.Instance, - destId: string, -): Collector.DestinationStatus { - if (!collector.status.destinations[destId]) { - collector.status.destinations[destId] = { - count: 0, - failed: 0, - duration: 0, - queuePushSize: 0, - dlqSize: 0, - }; - } - return collector.status.destinations[destId]; -} - -/** - * Bump a drop counter under `status.dropped[stepId][buffer]`. Lazily - * creates the per-step entry; returns the new counter value so callers - * can pass it straight into the warn-once log payload. - */ -function bumpDropped( - status: Collector.Status, - id: string, - buffer: 'queue' | 'dlq', - n: number, -): number { - if (!status.dropped[id]) status.dropped[id] = {}; - const entry = status.dropped[id]; - entry[buffer] = (entry[buffer] ?? 0) + n; - return entry[buffer]!; -} - /** * Resolves transformer chain for a destination. * @@ -389,6 +363,50 @@ export async function pushToDestinations( return { id, destination, skipped: true }; } + // Canonical id: the breaker gate, the failure/success accounting, and any + // aggregation MUST all key on the SAME id. A destination whose runtime + // `config.id` differs from its map key would otherwise touch two + // different breaker entries (gate vs accounting), so failures never + // accumulate against the entry the gate inspects and the breaker never + // opens. Resolve once here and thread it through the result. + const canonicalId = destination.config.id || id; + const breakerKey = stepId('destination', canonicalId); + const breakerConfig = resolveBreakerConfig(destination.config.breaker); + + // Circuit-breaker gate (same precedence as `config.disabled`): when the + // breaker is open, skip the event (counted as skipped, never pushed). + // Presence-gated: inert unless `config.breaker` is set. + if ( + breakerConfig && + isBreakerOpen( + collector.status.breakers, + breakerKey, + breakerConfig.cooldown, + ) + ) { + return { id, destination, skipped: true }; + } + + // Probe-settle helpers. When the gate above admitted a half-open probe, + // EVERY post-gate path must settle it: either record an outcome (the + // event reached the transport) or release the probe (it never did). + // Otherwise `probing` stays true and the breaker deadlocks half-open. + // Both are presence-gated and internally no-op unless half-open+probing. + const recordProbe = (outcome: 'transport-failure' | 'success') => { + if (breakerConfig) { + recordStepOutcome( + collector.status.breakers, + breakerKey, + outcome, + breakerConfig.threshold, + breakerConfig.cooldown, + ); + } + }; + const releaseProbeSlot = () => { + if (breakerConfig) releaseProbe(collector.status.breakers, breakerKey); + }; + // Queued events: refresh consent (full replace — stale consent must not persist). // User/globals merge happens for all events below in allowedEvents.map. let currentQueue = (destination.queuePush || []).map((event) => ({ @@ -410,6 +428,7 @@ export async function pushToDestinations( // If no events and no queued on events, skip this destination if (!currentQueue.length && !destination.queueOn?.length) { + releaseProbeSlot(); // probe admitted but no event to push return { id, destination, skipped: true }; } @@ -428,6 +447,7 @@ export async function pushToDestinations( // pushToDestinations after every state command, so the grant command // re-enters here with consent satisfied and inits then. if (!getGrantedConsent(destination.config.consent, consent)) { + releaseProbeSlot(); // probe admitted but consent gate denies push return { id, destination, skipped: true }; } let isInitialized = false; @@ -444,7 +464,13 @@ export async function pushToDestinations( collector.logger.scope(destType).error('destination init failed', { error: err instanceof Error ? err.message : String(err), }); + // A probe whose init throws is a real transport failure: re-open. + recordProbe('transport-failure'); } + // queueOn-only flush exercises no push, so a successful/false-returning + // init releases the probe (the transport-failure path already re-opened + // it, where release is a no-op). + releaseProbeSlot(); return { id, destination, skipped: !isInitialized }; } @@ -522,6 +548,7 @@ export async function pushToDestinations( // Execution shall not pass if no events are allowed if (!allowedEvents.length) { + releaseProbeSlot(); // probe admitted but every event was re-queued return { id, destination, queue: currentQueue }; // Don't push if not allowed } @@ -542,9 +569,18 @@ export async function pushToDestinations( collector.logger.scope(destType).error('destination init failed', { error: err instanceof Error ? err.message : String(err), }); + // A probe whose init throws is a real transport failure (the down + // destination is exactly the one whose init throws): re-open with a + // fresh window so self-heal does not deadlock. + recordProbe('transport-failure'); } - if (!isInitialized) return { id, destination, queue: currentQueue }; + if (!isInitialized) { + // Init returned false (no throw): the probe never pushed, so release it + // (no-op if the throw path above already re-opened). + releaseProbeSlot(); + return { id, destination, queue: currentQueue }; + } // Process the destinations event queue let error: unknown; @@ -815,6 +851,8 @@ export async function pushToDestinations( totalDuration, batchedCount, allowedCount: allowedEvents.length, + canonicalId, + breakerConfig, }; }), ); @@ -842,6 +880,24 @@ export async function pushToDestinations( destStatus.queuePushSize = destination.queuePush?.length ?? 0; destStatus.dlqSize = destination.dlq?.length ?? 0; + // Circuit-breaker accounting keys on the canonical stepId so it matches the + // gate exactly. Presence-gated: only when this destination has a breaker. + const breakerConfig = result.breakerConfig; + const breakerKey = result.canonicalId + ? stepId('destination', result.canonicalId) + : undefined; + const recordBreaker = (outcome: 'transport-failure' | 'success') => { + if (breakerConfig && breakerKey) { + recordStepOutcome( + collector.status.breakers, + breakerKey, + outcome, + breakerConfig.threshold, + breakerConfig.cooldown, + ); + } + }; + if (result.error) { ref.error = result.error; failed[result.id] = ref; @@ -849,6 +905,8 @@ export async function pushToDestinations( destStatus.lastAt = now; destStatus.duration += result.totalDuration || 0; collector.status.failed++; + // A single-event push that threw/timed out is a transport failure. + recordBreaker('transport-failure'); } else if (result.queue && result.queue.length) { // Events already re-queued at destination.queuePush via skippedEvents push queued[result.id] = ref; @@ -870,6 +928,10 @@ export async function pushToDestinations( destStatus.lastAt = now; destStatus.duration += result.totalDuration || 0; collector.status.out++; + // A synchronously-delivered single-event push is a success: reset and + // close the breaker. (Fully-batched passes settle in the flush + // callback instead, which records its own outcome.) + recordBreaker('success'); } } } @@ -934,6 +996,13 @@ export async function destinationInit( id: destId, config: destination.config, env: mergeEnvironments(destination.env, destination.config.env), + reportError: buildReportError( + collector, + 'destination', + destId, + destLogger, + destination, + ), }; destLogger.debug('init'); @@ -1043,6 +1112,13 @@ export async function destinationPush( ...mergeEnvironments(destination.env, config.env), ...(respond ? { respond } : {}), }, + reportError: buildReportError( + collector, + 'destination', + destId, + destLogger, + destination, + ), }; // Mock interception — replaces the actual destination.push() call @@ -1122,6 +1198,13 @@ export async function destinationPush( ...baseEnv, ...(rep.respond ? { respond: rep.respond } : {}), }, + reportError: buildReportError( + collector, + 'destination', + destId, + destLogger, + destination, + ), }; destLogger.debug('push batch', { events: snapshot.entries.length }); @@ -1129,6 +1212,28 @@ export async function destinationPush( const destIdResolved = destination.config.id || destId; const destStatus = ensureDestStatus(collector, destIdResolved); + // Circuit-breaker accounting for the batch path. Keys on the canonical + // stepId so it matches the gate. Presence-gated. A whole-batch throw is + // a transport failure; any delivered rows (full or partial success) are + // a success. Partial-failure rows themselves are breaker-neutral. + const flushBreakerConfig = resolveBreakerConfig( + destination.config.breaker, + ); + const flushBreakerKey = stepId('destination', destIdResolved); + const recordFlushBreaker = ( + outcome: 'transport-failure' | 'success', + ) => { + if (flushBreakerConfig) { + recordStepOutcome( + collector.status.breakers, + flushBreakerKey, + outcome, + flushBreakerConfig.threshold, + flushBreakerConfig.cooldown, + ); + } + }; + const flushStarted = Date.now(); const flushState = buildBaseState(collector, { stepId: stepId('destination', destId), @@ -1225,6 +1330,8 @@ export async function destinationPush( emitStep(collector, batchErrState); // Route the entire batch to DLQ on a thrown/whole-batch failure. routeToDlq(snapshot.entries.map((entry) => [entry.event, err])); + // Whole-batch throw is a transport failure for the breaker. + recordFlushBreaker('transport-failure'); destLogger.error('Push batch failed', { error: err instanceof Error ? err.message : String(err), entries: snapshot.entries.length, @@ -1277,6 +1384,10 @@ export async function destinationPush( destStatus.count += succeededCount; destStatus.lastAt = Date.now(); collector.status.out += succeededCount; + // Any delivered rows mean the transport worked: success closes the + // breaker. Partial-failure rows are breaker-neutral (handled by the + // absence of a transport-failure record for them above). + recordFlushBreaker('success'); } }; diff --git a/packages/collector/src/index.ts b/packages/collector/src/index.ts index 669545d5c..c6b1ea65c 100644 --- a/packages/collector/src/index.ts +++ b/packages/collector/src/index.ts @@ -5,6 +5,14 @@ export * from './constants'; export * from './consent'; export * from './flow'; export * from './push'; +export * from './report-error'; +export { + isBreakerProbePermitted, + resolveBreakerConfig, + DEFAULT_BREAKER_THRESHOLD, + DEFAULT_BREAKER_COOLDOWN_MS, +} from './breaker'; +export type { BreakerConfig, StepOutcome } from './breaker'; export * from './destination'; export * from './handle'; export * from './on'; diff --git a/packages/collector/src/on.ts b/packages/collector/src/on.ts index f41815bdc..a9e85e962 100644 --- a/packages/collector/src/on.ts +++ b/packages/collector/src/on.ts @@ -9,6 +9,7 @@ import { isArray, FatalError } from '@walkeros/core'; import { Const } from './constants'; import { tryCatch, tryCatchAsync } from '@walkeros/core'; import { mergeEnvironments } from './destination'; +import { buildReportError } from './report-error'; import { reconcilePending } from './pending'; import { flushSourceQueueOn, isSourceStarted } from './source'; @@ -386,6 +387,13 @@ export function callDestinationOn( config: destination.config, data: data as Destination.Data, env: mergeEnvironments(destination.env, destination.config.env), + reportError: buildReportError( + collector, + 'destination', + destId, + destLogger, + destination, + ), }; tryCatch(destination.on, (err) => diff --git a/packages/collector/src/report-error.ts b/packages/collector/src/report-error.ts new file mode 100644 index 000000000..bdffbf058 --- /dev/null +++ b/packages/collector/src/report-error.ts @@ -0,0 +1,177 @@ +import type { + Collector, + Context, + Destination, + Logger, + StepKind, + WalkerOS, +} from '@walkeros/core'; +import { stepId } from '@walkeros/core'; +import { pushBounded, resetOverflowFlag, warnOverflowOnce } from './buffers'; +import { recordStepOutcome, resolveBreakerConfig } from './breaker'; + +/** + * Maximum number of failed-push entries retained per destination DLQ before + * FIFO drop-oldest. Mirrored by `Destination.Config.dlqMax`. + */ +export const DEFAULT_DLQ_MAX = 100; + +/** + * Ensure a per-destination status entry exists and return it. + */ +export function ensureDestStatus( + collector: Collector.Instance, + destId: string, +): Collector.DestinationStatus { + if (!collector.status.destinations[destId]) { + collector.status.destinations[destId] = { + count: 0, + failed: 0, + duration: 0, + queuePushSize: 0, + dlqSize: 0, + }; + } + return collector.status.destinations[destId]; +} + +/** + * Bump a drop counter under `status.dropped[stepId][buffer]`. Lazily + * creates the per-step entry; returns the new counter value so callers + * can pass it straight into the warn-once log payload. + */ +export function bumpDropped( + status: Collector.Status, + id: string, + buffer: 'queue' | 'dlq', + n: number, +): number { + if (!status.dropped[id]) status.dropped[id] = {}; + const entry = status.dropped[id]; + entry[buffer] = (entry[buffer] ?? 0) + n; + return entry[buffer]!; +} + +/** + * Routes a single event/error pair to a destination's DLQ with the bounded + * write, overflow-warn-once, and full failure accounting (`destStatus.failed` + * + `collector.status.failed`). This mirrors the batch flush's `routeToDlq` + * for the single-event case: write to the DLQ AND bump the failed counters in + * one place. (The per-event in-band push path bumps `failed` later in the + * aggregation pass instead, so it does NOT call this; this helper is for the + * out-of-band `reportError(err, event)` seam that has no aggregation pass.) + */ +function routeEventToDlq( + collector: Collector.Instance, + destination: Destination.Instance, + destId: string, + event: WalkerOS.Event, + err: unknown, + logger: Logger.Instance, +): void { + const dlq = (destination.dlq = destination.dlq || []); + const dlqBound = { max: destination.config.dlqMax ?? DEFAULT_DLQ_MAX }; + const dlqResult = pushBounded(dlq, [event, err], dlqBound); + if (dlqResult.dropped > 0) { + const droppedCount = bumpDropped( + collector.status, + stepId('destination', destId), + 'dlq', + dlqResult.dropped, + ); + warnOverflowOnce( + dlq, + logger, + 'destination.dlq overflow; oldest entries dropped', + { + buffer: 'dlq', + destination: destId, + cap: dlqBound.max, + droppedCount, + }, + ); + } else if (dlq.length < dlqBound.max) { + resetOverflowFlag(dlq); + } + const destStatus = ensureDestStatus(collector, destId); + destStatus.failed++; + destStatus.dlqSize = dlq.length; + collector.status.failed++; + + // A connection-level error that DLQs a specific event is a transport + // failure: feed it to the circuit breaker so the gate picks it up. + // Presence-gated and keyed on the canonical stepId (matching the push gate). + const breakerConfig = resolveBreakerConfig(destination.config.breaker); + if (breakerConfig) { + const canonicalId = destination.config.id || destId; + recordStepOutcome( + collector.status.breakers, + stepId('destination', canonicalId), + 'transport-failure', + breakerConfig.threshold, + breakerConfig.cooldown, + ); + } +} + +/** + * Builds the step-general `reportError` callback for one step's context. + * + * This is the runtime behind `Context.Base.reportError`. It is captured ONCE + * when a step's context is built and closes over `(collector, kind, id, + * logger, destination)`, so a long-lived connection that holds the reference + * keeps a valid callback for its whole lifetime — it is never rebuilt per + * push. It runs on a detached emitter tick, so every path is internally + * try/catch-guarded; a throw inside it would reintroduce the very + * uncaughtException it exists to contain. + * + * - orphan (`reportError(err)`): bump `status.connectionErrors[stepId]` and + * `logger.error`. Does NOT bump `failed` (no event lost at this instant; + * counting it against `failed` would double-count the next push that hits + * the broken writer and gets DLQ'd). + * - event-bearing (`reportError(err, event)`): route the event through the + * same DLQ + failure accounting an in-band push failure uses (for a + * destination). A step kind without a DLQ (or a destination not passed) + * still gets `failed`-counted and logged, never silently dropped. + * + * The with-event path deliberately funnels through the normal failure + * accounting so that when circuit-breaker accounting is later attached to that + * path, `reportError(err, event)` picks it up with no further wiring here. + */ +export function buildReportError( + collector: Collector.Instance, + kind: Exclude, + id: string, + logger: Logger.Instance, + destination?: Destination.Instance, +): NonNullable { + const key = stepId(kind, id); + return (err: unknown, event?: WalkerOS.Event): void => { + try { + if (event) { + if (destination) { + routeEventToDlq(collector, destination, id, event, err, logger); + } else { + // No DLQ buffer for this step kind: still account + surface the + // lost event so it is never silent. + collector.status.failed++; + } + logger.error('reportError', { + error: err instanceof Error ? err.message : String(err), + event: event.name, + }); + return; + } + + // Orphan / connection-level error: count it under connectionErrors, + // not failed. + collector.status.connectionErrors[key] = + (collector.status.connectionErrors[key] ?? 0) + 1; + logger.error('connection error', { + error: err instanceof Error ? err.message : String(err), + }); + } catch { + // Contained: reportError runs on a detached tick and must never throw. + } + }; +} diff --git a/packages/collector/src/source.ts b/packages/collector/src/source.ts index 7a5ad5c1f..6bb2ed61b 100644 --- a/packages/collector/src/source.ts +++ b/packages/collector/src/source.ts @@ -27,6 +27,7 @@ import { runTransformerChain, cloneIngest, } from './transformer'; +import { buildReportError } from './report-error'; import { isStateDelivery, shouldDeliver, setMark } from './on'; import { reconcilePending } from './pending'; @@ -535,6 +536,7 @@ export async function initSource( config, env: cleanEnv, withScope, + reportError: buildReportError(collector, 'source', sourceId, initialLogger), }; const sourceInstance = await tryCatchAsync( diff --git a/packages/collector/src/store.ts b/packages/collector/src/store.ts index 5b27524e5..42700e381 100644 --- a/packages/collector/src/store.ts +++ b/packages/collector/src/store.ts @@ -3,6 +3,7 @@ import { emitStep, useHooks } from '@walkeros/core'; import { createCacheStore } from './cache-store'; import { buildBaseState } from './observerEmit'; import { wrapStoreWithCache } from './store-cache-wrapper'; +import { buildReportError } from './report-error'; /** * Narrowed view of a `Store.InitStore` entry that also carries the optional @@ -298,6 +299,7 @@ export async function initStores( id: storeId, config, env, + reportError: buildReportError(collector, 'store', storeId, storeLogger), }; const instance = await code(context); diff --git a/packages/collector/src/transformer.ts b/packages/collector/src/transformer.ts index aa581a0af..e769ca759 100644 --- a/packages/collector/src/transformer.ts +++ b/packages/collector/src/transformer.ts @@ -58,6 +58,7 @@ import { } from '@walkeros/core'; import { buildBaseState } from './observerEmit'; import { getCacheStore, getStateStore } from './cache'; +import { buildReportError } from './report-error'; /** * Extracts transformer next configuration for chain walking. @@ -260,6 +261,12 @@ export async function initTransformers( ingest: createIngest(transformerId), config: configWithState, env: env as Transformer.Env, + reportError: buildReportError( + collector, + 'transformer', + transformerId, + transformerLogger, + ), }; // Synthesize a passthrough instance when `code` is absent. @@ -397,6 +404,12 @@ export async function transformerInit( ingest: createIngest(transformerId), config: transformer.config, env: mergeTransformerEnvironments(transformer.config.env), + reportError: buildReportError( + collector, + 'transformer', + transformerId, + transformerLogger, + ), }; transformerLogger.debug('init'); @@ -459,6 +472,12 @@ export async function transformerPush( ...mergeTransformerEnvironments(transformer.config.env), ...(respond ? { respond } : {}), }, + reportError: buildReportError( + collector, + 'transformer', + transformerId, + transformerLogger, + ), }; transformerLogger.debug('push', { event: (event as { name?: string }).name }); diff --git a/packages/core/src/__tests__/emitStep.test.ts b/packages/core/src/__tests__/emitStep.test.ts index cca1c2ddc..d9a19d155 100644 --- a/packages/core/src/__tests__/emitStep.test.ts +++ b/packages/core/src/__tests__/emitStep.test.ts @@ -31,6 +31,8 @@ function makeCollector(observers: Set): Collector.Instance { sources: {}, destinations: {}, dropped: {}, + connectionErrors: {}, + breakers: {}, }, timing: 0, user: {}, diff --git a/packages/core/src/schemas/destination.ts b/packages/core/src/schemas/destination.ts index da049cab9..2d7b13da9 100644 --- a/packages/core/src/schemas/destination.ts +++ b/packages/core/src/schemas/destination.ts @@ -189,6 +189,18 @@ export const ConfigSchema = z .describe( "Enables batching for all of this destination's events into one shared default buffer; a mapping rule's own batch splits that entity-action into its own buffer and overrides per field. Bare number is the debounce wait window; object form supports wait (debounce ms), size (count cap, default 1000), age (max ms since first entry, default 30000).", ), + breaker: z + .union([ + z.number(), + z.object({ + threshold: z.number().optional(), + cooldown: z.number().optional(), + }), + ]) + .optional() + .describe( + 'Per-destination circuit breaker (presence-gated). After threshold consecutive transport failures the breaker opens and events are skipped until cooldown ms elapse, then one probe is admitted; success closes it, failure re-opens it. Partial-batch row failures are breaker-neutral. Bare number is the threshold; object form supports threshold (default 5) and cooldown (default 30000ms).', + ), }) .meta({ id: 'DestinationConfig', diff --git a/packages/core/src/types/collector.ts b/packages/core/src/types/collector.ts index a1417e984..0ce31577b 100644 --- a/packages/core/src/types/collector.ts +++ b/packages/core/src/types/collector.ts @@ -14,20 +14,25 @@ import type { Ingest } from './ingest'; import type { ObserverFn } from './observer'; /** Identifies which kind of step a stepId belongs to. */ -export type StepKind = 'collector' | 'source' | 'transformer' | 'destination'; +export type StepKind = + | 'collector' + | 'source' + | 'transformer' + | 'destination' + | 'store'; /** - * Build a stepId for use as a key in `Status.dropped` (and future - * status maps). The collector-level stepId is the literal "collector" - * (no id). Source/transformer/destination ids take the form - * `"."`, e.g. `"destination.ga4"`. + * Build a stepId for use as a key in `Status.dropped` / + * `Status.connectionErrors` (and future status maps). The collector-level + * stepId is the literal "collector" (no id). Source/transformer/destination/ + * store ids take the form `"."`, e.g. `"destination.ga4"`. * * The dot separator mirrors the vocabulary already used in collector * log messages ("collector.queue overflow", "destination.dlq overflow"). */ export function stepId(kind: 'collector'): 'collector'; export function stepId( - kind: 'source' | 'transformer' | 'destination', + kind: 'source' | 'transformer' | 'destination' | 'store', id: string, ): string; export function stepId(kind: StepKind, id?: string): string { @@ -48,6 +53,32 @@ export interface DroppedCounters { dlq?: number; } +/** + * Circuit-breaker state for a single step (keyed by `stepId()` in + * `Status.breakers`). Tracks consecutive transport failures so a step whose + * transport is down can be skipped (gated) until a cooldown elapses, instead + * of every event hammering a known-broken writer. + * + * - `closed`: healthy; events pass through. `consecutiveFailures` accrues on + * transport failures and resets to 0 on any success. + * - `open`: tripped; events are skipped until `openUntil`. The first event at + * or after `openUntil` transitions to `half-open` and becomes the probe. + * - `half-open`: a single probe event is allowed through; `probing` marks that + * the probe slot is taken so concurrent events still skip. A probe success + * closes the breaker; a probe failure re-opens it with a fresh `openUntil`. + * + * `consecutiveFailures` is CONSECUTIVE, not cumulative: a single success + * resets it, so only an unbroken run reaching the threshold opens the breaker. + */ +export interface BreakerState { + state: 'closed' | 'open' | 'half-open'; + consecutiveFailures: number; + /** Epoch ms after which an open breaker admits a single probe. */ + openUntil?: number; + /** True while a half-open probe is in flight (probe slot taken). */ + probing?: boolean; +} + /** * Core collector configuration interface */ @@ -121,6 +152,39 @@ export interface Status { * - `dropped["destination.ga4"]?.dlq`: ga4's dead-letter queue drops */ dropped: Record; + /** + * Per-step circuit-breaker state, keyed by stepId. See `stepId()` for key + * construction; keyed step-agnostically (NOT embedded in + * `DestinationStatus`) so the breaker can guard any step kind, though + * destinations are the primary use today. A breaker is created lazily on + * first accounting and stays inert unless its step is configured with a + * `breaker` (presence-gated): existing flows never trip a breaker. + * + * A runtime status field, not drift-guarded (it is observed, never authored + * in a flow config). + * + * Example: + * - `breakers["destination.bigquery"]`: bigquery's consecutive-failure gate. + */ + breakers: Record; + /** + * Monotonic counts of out-of-band connection-level errors reported by a + * step via `context.reportError(err)` with no event (an orphan error from + * an EventEmitter SDK object's `'error'` handler), keyed by stepId. See + * `stepId()` for key construction; mirrors `dropped`. + * + * Distinct from `failed`: `failed` counts events lost in-band (a push that + * threw, a DLQ'd entry). `connectionErrors` counts connection faults that + * did not lose a specific event at the moment they fired. Operators read + * both: a rising `connectionErrors` with flat `failed` means a writer is + * flapping but events are still landing; both rising means the fault is + * now dropping events. + * + * Example: + * - `connectionErrors["destination.bigquery"]`: BigQuery stream writer + * `'error'` events reported between pushes. + */ + connectionErrors: Record; } export interface SourceStatus { diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index 171da317f..5a549fa31 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -1,4 +1,4 @@ -import type { Collector, Logger } from '.'; +import type { Collector, Logger, WalkerOS } from '.'; /** * Base context interface for walkerOS stages. @@ -9,4 +9,27 @@ export interface Base { logger: Logger.Instance; config: C; env: E; + /** + * Out-of-band error seam available to every step kind (source, + * transformer, store, destination). A step that owns an EventEmitter SDK + * object (BigQuery `StreamConnection`, a Redis client, a Kafka producer) + * calls this from the object's `'error'` handler, where there is no + * surrounding `await`/`tryCatchAsync` to catch into and an unhandled + * `'error'` would otherwise crash the process on a detached tick. + * + * MUST NOT throw (it runs on a detached emitter tick; a throw inside it + * would reintroduce the uncaughtException it exists to prevent). + * + * - With `event`: routes that event through the same per-step failure + * accounting an in-band push failure uses (the destination DLQ + the + * `failed` counters), so a connection error that loses a specific event + * is counted and surfaced exactly like a synchronous push failure. + * - Without `event` (an orphan / connection-level error, e.g. a stream + * writer that errored between pushes): a contained `logger.error` plus a + * bump of `Status.connectionErrors[stepId]`. It does NOT bump + * `Status.failed` (no event was lost at this instant; the next push that + * hits the broken writer is what gets DLQ'd and counted as failed, so + * counting the orphan against `failed` would double-count). + */ + reportError?(err: unknown, event?: WalkerOS.Event): void; } diff --git a/packages/core/src/types/destination.ts b/packages/core/src/types/destination.ts index ca948d9ee..4d6eda69b 100644 --- a/packages/core/src/types/destination.ts +++ b/packages/core/src/types/destination.ts @@ -166,6 +166,31 @@ export interface Config { * - `age`: time since the first entry of the current window. Forces a flush even if pushes keep arriving. Default 30000 (30s). */ batch?: number | BatchOptions; + /** + * Per-destination circuit breaker. Presence-gated: when unset the breaker is + * inert and behavior is unchanged. After `threshold` CONSECUTIVE transport + * failures (a thrown push, a timed-out push, a whole-batch throw, a + * connection-level `reportError(err, event)`) the breaker opens and events + * are skipped (counted, not pushed) until `cooldown` ms elapse, then a single + * probe event is allowed; its success closes the breaker, its failure + * re-opens it. Partial-batch row failures are breaker-neutral. A bare number + * is the `threshold`. + * + * - `threshold`: consecutive transport failures before opening. Default 5. + * - `cooldown`: ms an open breaker waits before admitting a probe. Default 30000. + */ + breaker?: number | BreakerOptions; +} + +/** + * Circuit-breaker tuning. Used at the destination-config layer + * (`Destination.Config.breaker`). A bare number is treated as `threshold`. + */ +export interface BreakerOptions { + /** Consecutive transport failures before the breaker opens. Default 5. */ + threshold?: number; + /** Milliseconds an open breaker waits before admitting a probe. Default 30000. */ + cooldown?: number; } /** diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 05b8a5cb2..4e01c653c 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -3,7 +3,7 @@ export * as Collector from './collector'; // Direct re-export so consumers can call `stepId('collector')` without // going through the `Collector` namespace. export { stepId } from './collector'; -export type { StepKind, DroppedCounters } from './collector'; +export type { StepKind, DroppedCounters, BreakerState } from './collector'; export * as Context from './context'; export type { ServiceAccount, Credential } from './credentials'; export * as Destination from './destination'; diff --git a/packages/server/destinations/gcp/src/bigquery/__mocks__/@google-cloud/bigquery-storage.ts b/packages/server/destinations/gcp/src/bigquery/__mocks__/@google-cloud/bigquery-storage.ts index 6fb70eeb4..92a726013 100644 --- a/packages/server/destinations/gcp/src/bigquery/__mocks__/@google-cloud/bigquery-storage.ts +++ b/packages/server/destinations/gcp/src/bigquery/__mocks__/@google-cloud/bigquery-storage.ts @@ -1,3 +1,5 @@ +import { EventEmitter } from 'events'; + const calls: Array<{ method: string; args: unknown[] }> = []; interface MockRowError { @@ -11,6 +13,50 @@ let nextAppendThrow: unknown = null; let nextGetResultReject: unknown = null; let nextCreateStreamConnectionError: unknown = null; +/** + * Honest stand-in for the SDK's StreamConnection: a REAL Node EventEmitter so + * `emit('error', …)` with no listener throws exactly like the real one (Node's + * special-cased `'error'` event). `onConnectionError` mirrors the SDK surface: + * it registers an `'error'` listener and returns an `{ off }` disposable. + */ +class MockStreamConnection extends EventEmitter { + private readonly streamId: string; + constructor(streamId: string) { + super(); + this.streamId = streamId; + } + getStreamId(): string { + return this.streamId; + } + onConnectionError(listener: (err: unknown) => void): { off: () => void } { + calls.push({ method: 'onConnectionError', args: [] }); + this.on('error', listener); + return { + off: () => { + this.off('error', listener); + }, + }; + } + /** + * Test-only: literally `this.emit('error', err)` with NO internal + * listener-count guard, so before a listener is attached this reproduces + * Node's real "throw if no 'error' listener" behavior. A fake that silently + * swallowed would mask the very crash this listener exists to contain. + */ + __emitConnectionError(err: unknown): void { + this.emit('error', err); + } +} + +// The most recently created connection, so tests can emit on it and assert the +// listener was registered. +let lastConnection: MockStreamConnection | null = null; + +// Every connection ever created (this reset cycle), so tests can assert that a +// concurrent re-open did not orphan a connection still carrying an 'error' +// listener. +const allConnections: MockStreamConnection[] = []; + class MockJSONWriter { constructor(_args: { connection: unknown; protoDescriptor: unknown }) { calls.push({ method: 'JSONWriter.ctor', args: [_args] }); @@ -76,9 +122,12 @@ class MockWriterClient { nextCreateStreamConnectionError = null; throw queuedError; } - return { - getStreamId: () => `${args.destinationTable}/streams/_default`, - }; + const connection = new MockStreamConnection( + `${args.destinationTable}/streams/_default`, + ); + lastConnection = connection; + allConnections.push(connection); + return connection; } async getWriteStream( args: { streamId: string; view?: number }, @@ -142,6 +191,27 @@ function __resetMockCalls() { nextAppendThrow = null; nextGetResultReject = null; nextCreateStreamConnectionError = null; + lastConnection = null; + allConnections.length = 0; +} + +/** + * Test-only: the most recently created StreamConnection, so a test can emit an + * out-of-band `'error'` on it (via `__emitConnectionError`) and assert the + * destination attached a listener. + */ +function __getLastConnection(): MockStreamConnection { + if (!lastConnection) throw new Error('mock: no StreamConnection created yet'); + return lastConnection; +} + +/** + * Test-only: every StreamConnection created since the last reset, so a test can + * assert that a concurrent re-open did not orphan a connection that still + * carries an 'error' listener (a leak). + */ +function __getAllConnections(): MockStreamConnection[] { + return allConnections.slice(); } /** @@ -182,8 +252,11 @@ export { managedwriter, adapt, protos, + MockStreamConnection, __getMockCalls, __resetMockCalls, + __getLastConnection, + __getAllConnections, __setNextAppendRowErrors, __setNextAppendThrow, __setNextGetResultReject, diff --git a/packages/server/destinations/gcp/src/bigquery/__tests__/bigquery-storage-mock.d.ts b/packages/server/destinations/gcp/src/bigquery/__tests__/bigquery-storage-mock.d.ts index efa2e05b4..9d3f43769 100644 --- a/packages/server/destinations/gcp/src/bigquery/__tests__/bigquery-storage-mock.d.ts +++ b/packages/server/destinations/gcp/src/bigquery/__tests__/bigquery-storage-mock.d.ts @@ -1,6 +1,15 @@ declare module '@google-cloud/bigquery-storage' { + import type { EventEmitter } from 'events'; + export class MockStreamConnection extends EventEmitter { + constructor(streamId: string); + getStreamId(): string; + onConnectionError(listener: (err: unknown) => void): { off: () => void }; + __emitConnectionError(err: unknown): void; + } export function __getMockCalls(): Array<{ method: string; args: unknown[] }>; export function __resetMockCalls(): void; + export function __getLastConnection(): MockStreamConnection; + export function __getAllConnections(): MockStreamConnection[]; export function __setNextAppendRowErrors( errors: Array<{ index: number; code: number; message: string }>, ): void; diff --git a/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts b/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts index b80ff2b3f..f61db7643 100644 --- a/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts +++ b/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import type { Collector } from '@walkeros/core'; +import type { Collector, WalkerOS } from '@walkeros/core'; import type { Config, Destination, @@ -14,6 +14,8 @@ import { __setNextAppendThrow, __setNextGetResultReject, __setNextOpenWriterError, + __getLastConnection, + __getAllConnections, } from '@google-cloud/bigquery-storage'; import { clone, @@ -46,6 +48,7 @@ describe('Server Destination BigQuery', () => { initSettings: InitSettings, logger?: MockLogger, timeout?: number, + reportError?: (err: unknown, event?: WalkerOS.Event) => void, ) { if (!destination.init) throw new Error('destination.init undefined'); return destination.init({ @@ -57,6 +60,7 @@ describe('Server Destination BigQuery', () => { env: testEnv, logger: logger ?? createMockLogger(), id: 'test-bq', + reportError: reportError ?? (() => undefined), }); } @@ -80,6 +84,9 @@ describe('Server Destination BigQuery', () => { expect(methods).toEqual([ 'WriterClient.ctor', 'createStreamConnection', + // The connection-error listener is attached between opening the stream and + // building the JSONWriter so a detached `'error'` is contained, not thrown. + 'onConnectionError', 'getWriteStream', 'adapt.convertStorageSchemaToProto2Descriptor', 'JSONWriter.ctor', @@ -724,4 +731,182 @@ describe('Server Destination BigQuery', () => { ).rejects.toThrow('Deadline exceeded'); }); }); + + describe("connection 'error' containment + self-heal", () => { + test('a non-retryable stream error sets writerBroken, calls reportError (orphan), and DLQ-routes the next push', async () => { + const reportError = jest.fn(); + const config = await callInit( + { projectId, datasetId, tableId }, + undefined, + undefined, + reportError, + ); + if (!config) throw new Error('init returned void'); + const { settings } = config; + if (!settings) throw new Error('settings missing after init'); + + // Emit an out-of-band, non-retryable stream error on the live connection. + // With the listener attached this does NOT throw (containment). + const streamError: Error & { code?: number } = Object.assign( + new Error('INVALID_ARGUMENT: stream is permanently broken'), + { code: 3 }, + ); + expect(() => + __getLastConnection().__emitConnectionError(streamError), + ).not.toThrow(); + + // Orphan reportError form: called with the error and NO event. + expect(reportError).toHaveBeenCalledTimes(1); + expect(reportError).toHaveBeenCalledWith(streamError); + expect(reportError.mock.calls[0]).toHaveLength(1); + + // The destination flagged itself broken with the last error captured. + expect(settings.writerBroken).toBe(true); + expect(settings.lastStreamError).toBe(streamError); + + // The next push self-heals (one re-open). Make that re-open FAIL so the + // event is DLQ-routed in-band (throws) rather than crashing out-of-band. + __setNextOpenWriterError( + Object.assign(new Error('re-open failed'), { code: 14 }), + ); + + await expect( + destination.push( + event, + createMockContext({ + config, + rule: undefined, + data: undefined, + env: testEnv, + id: 'test-bq', + }), + ), + ).rejects.toThrow('re-open failed'); + + // Stayed broken after a failed re-open. + expect(settings.writerBroken).toBe(true); + }); + + test('the next push self-heals (re-opens) and delivers when writerBroken', async () => { + const config = await callInit({ projectId, datasetId, tableId }); + if (!config) throw new Error('init returned void'); + const { settings } = config; + if (!settings) throw new Error('settings missing after init'); + + // Break the writer via the connection error path. + __getLastConnection().__emitConnectionError(new Error('stream gone')); + expect(settings.writerBroken).toBe(true); + const brokenWriter = settings.writer; + + __resetMockCalls(); + + // Next push: re-open succeeds (no queued openWriter error), flag clears, + // and the row is appended on the fresh writer. + await destination.push( + event, + createMockContext({ + config, + rule: undefined, + data: undefined, + env: testEnv, + id: 'test-bq', + }), + ); + + expect(settings.writerBroken).toBe(false); + expect(settings.lastStreamError).toBeUndefined(); + expect(settings.writer).not.toBe(brokenWriter); + + const methods = __getMockCalls().map((c) => c.method); + // A fresh writer was opened (re-open) and a row appended. + expect(methods).toContain('JSONWriter.ctor'); + expect(methods).toContain('appendRows'); + }); + + test('a broken writer DLQ-routes the whole batch when re-open fails (batch path)', async () => { + if (!destination.pushBatch) throw new Error('pushBatch missing'); + + const config = await callInit({ projectId, datasetId, tableId }); + if (!config) throw new Error('init returned void'); + const { settings } = config; + if (!settings) throw new Error('settings missing after init'); + + __getLastConnection().__emitConnectionError(new Error('stream gone')); + expect(settings.writerBroken).toBe(true); + + // Re-open fails on the batch path -> whole batch throws (DLQ). + __setNextOpenWriterError(new Error('re-open failed (batch)')); + + const logger = createMockLogger(); + const events = [createEvent(), createEvent()]; + const data: Array = events.map(() => undefined); + const entries = events.map((e) => ({ event: e })); + + await expect( + destination.pushBatch!( + { key: 'k', events, data, entries }, + createMockContext({ + config, + env: testEnv, + logger, + id: 'test-bq', + }), + ), + ).rejects.toThrow('re-open failed (batch)'); + + expect(settings.writerBroken).toBe(true); + }); + + test('concurrent pushes on a broken writer trigger exactly ONE re-open (no orphaned connection or listener)', async () => { + const config = await callInit({ projectId, datasetId, tableId }); + if (!config) throw new Error('init returned void'); + const { settings } = config; + if (!settings) throw new Error('settings missing after init'); + + // Break the writer. The original (now broken) connection still holds its + // 'error' listener until the re-open closes it. + const brokenConnection = __getLastConnection(); + brokenConnection.__emitConnectionError(new Error('stream gone')); + expect(settings.writerBroken).toBe(true); + expect(brokenConnection.listenerCount('error')).toBe(1); + + __resetMockCalls(); + + // Two pushes admitted in the same breaking pass (breaker still CLOSED): + // the collector would fan these out concurrently. Without the in-flight + // memo each would close+re-open, orphaning a connection + listener. + const ctx = () => + createMockContext({ + config, + rule: undefined, + data: undefined, + env: testEnv, + id: 'test-bq', + }); + await Promise.all([ + destination.push(event, ctx()), + destination.push(event, ctx()), + ]); + + // Exactly ONE re-open: one new connection, one new JSONWriter. + const methods = __getMockCalls().map((c) => c.method); + expect( + methods.filter((m) => m === 'createStreamConnection'), + ).toHaveLength(1); + expect(methods.filter((m) => m === 'JSONWriter.ctor')).toHaveLength(1); + + // The writer self-healed and both rows were appended on the one writer. + expect(settings.writerBroken).toBe(false); + expect(methods.filter((m) => m === 'appendRows')).toHaveLength(2); + + // No orphaned connection: exactly one connection (the live one) still + // carries an 'error' listener; the broken one was .off()'d on re-open. + const leaked = __getAllConnections().filter( + (c) => c.listenerCount('error') > 0, + ); + expect(leaked).toHaveLength(1); + expect(leaked[0]).toBe(settings.connection); + expect(brokenConnection.listenerCount('error')).toBe(0); + }); + }); }); diff --git a/packages/server/destinations/gcp/src/bigquery/__tests__/stepExamples.test.ts b/packages/server/destinations/gcp/src/bigquery/__tests__/stepExamples.test.ts index 1e13cfa45..66cf7a776 100644 --- a/packages/server/destinations/gcp/src/bigquery/__tests__/stepExamples.test.ts +++ b/packages/server/destinations/gcp/src/bigquery/__tests__/stepExamples.test.ts @@ -46,6 +46,7 @@ describe('Step Examples', () => { env: testEnv, logger: createMockLogger(), id: 'test-bq', + reportError: () => undefined, })) as Config; await destination.push( diff --git a/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts b/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts index 3c695f89d..f9805cce3 100644 --- a/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts +++ b/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts @@ -2,6 +2,8 @@ import { createMockLogger } from '@walkeros/core'; import { __getMockCalls, __resetMockCalls, + __getLastConnection, + MockStreamConnection, } from '@google-cloud/bigquery-storage'; import { openWriter, closeWriter } from '../writer'; @@ -92,4 +94,69 @@ describe('openWriter', () => { expect(methods).toContain('JSONWriter.close'); expect(methods).toContain('WriterClient.close'); }); + + describe("connection 'error' listener (out-of-band containment)", () => { + // Honesty guard: the mock StreamConnection is a REAL EventEmitter, so an + // `emit('error', …)` with NO listener throws exactly like Node does. This + // is the crash class this listener exists to prevent. + test('a listener-less connection error throws (reproduces the crash)', () => { + const conn = new MockStreamConnection('s'); + const boom: Error & { code?: number } = Object.assign( + new Error('stream died'), + { code: 13 }, + ); + // No listener attached -> Node re-throws the emitted error synchronously. + expect(() => conn.__emitConnectionError(boom)).toThrow('stream died'); + }); + + test('openWriter attaches a listener so the same error is contained', async () => { + const logger = createMockLogger(); + const captured: unknown[] = []; + await openWriter( + { + projectId: 'p', + datasetId: 'd', + tableId: 't', + onConnectionError: (err) => { + captured.push(err); + }, + }, + logger, + ); + + // The destination registered via the SDK's documented hook. + const methods = __getMockCalls().map((c) => c.method); + expect(methods).toContain('onConnectionError'); + + const conn = __getLastConnection(); + expect(conn.listenerCount('error')).toBe(1); + + const boom: Error & { code?: number } = Object.assign( + new Error('stream died'), + { code: 13 }, + ); + // With the listener in place the emit no longer throws: it is contained + // and routed to the supplied handler. + expect(() => conn.__emitConnectionError(boom)).not.toThrow(); + expect(captured).toEqual([boom]); + }); + + test('closeWriter removes the connection-error listener', async () => { + const logger = createMockLogger(); + const handles = await openWriter( + { + projectId: 'p', + datasetId: 'd', + tableId: 't', + onConnectionError: () => undefined, + }, + logger, + ); + const conn = __getLastConnection(); + expect(conn.listenerCount('error')).toBe(1); + + closeWriter(handles, logger); + expect(conn.listenerCount('error')).toBe(0); + }); + }); }); diff --git a/packages/server/destinations/gcp/src/bigquery/index.ts b/packages/server/destinations/gcp/src/bigquery/index.ts index 03463a822..5b724a4cc 100644 --- a/packages/server/destinations/gcp/src/bigquery/index.ts +++ b/packages/server/destinations/gcp/src/bigquery/index.ts @@ -21,7 +21,7 @@ export const destinationBigQuery: Destination = { setup, - async init({ config: partialConfig, env, logger, id }) { + async init({ config: partialConfig, env, logger, id, reportError }) { const config = getConfig(partialConfig, env, logger); // The gax deadline derives from the standard per-step config.timeout (the @@ -32,21 +32,58 @@ export const destinationBigQuery: Destination = { ? config.timeout : DEFAULT_TIMEOUT_MS; - // Open the long-lived JSONWriter on the _default stream. - // Hard-fail when the dataset/table is missing. - try { - const { writer, writeClient } = await openWriter( + const { settings } = config; + + // Handler for the StreamConnection's out-of-band `'error'` event. Attaching + // it prevents Node's uncaught-`'error'` crash on the detached gRPC tick. It + // flags the writer broken (so the next push self-heals or DLQs in-band) and + // routes the error through the Task-2 ORPHAN reportError seam (no event): + // a redacted, ring-tapped log plus a connection-error counter bump. MUST + // NOT throw (detached emitter tick). + const onConnectionError = (err: unknown): void => { + settings.writerBroken = true; + settings.lastStreamError = + err instanceof Error ? err : new Error(String(err)); + reportError?.(err); + }; + + // Lazy re-open hook used by ensureWriter on the push path to self-heal a + // broken writer. Closes over the openWriter args + onConnectionError so the + // fresh connection carries the same containment handler. The reused args + // (projectId/datasetId/tableId/bigquery/timeout) are immutable post-init, so + // a re-open targets the same table with the same auth and deadline. + settings.reopenWriter = () => + openWriter( { - projectId: config.settings.projectId, - datasetId: config.settings.datasetId, - tableId: config.settings.tableId, - bigquery: config.settings.bigquery, + projectId: settings.projectId, + datasetId: settings.datasetId, + tableId: settings.tableId, + bigquery: settings.bigquery, timeout, + onConnectionError, }, logger, ); - config.settings.writer = writer; - config.settings.writeClient = writeClient; + + // Open the long-lived JSONWriter on the _default stream. + // Hard-fail when the dataset/table is missing. + try { + const { writer, writeClient, connection, connectionErrorListener } = + await openWriter( + { + projectId: settings.projectId, + datasetId: settings.datasetId, + tableId: settings.tableId, + bigquery: settings.bigquery, + timeout, + onConnectionError, + }, + logger, + ); + settings.writer = writer; + settings.writeClient = writeClient; + settings.connection = connection; + settings.connectionErrorListener = connectionErrorListener; } catch (err) { // Log the failure and rethrow the raw error. Secret redaction is // standardized at the CLI logger handler, which scrubs every line before @@ -93,6 +130,7 @@ export const destinationBigQuery: Destination = { { writer: config.settings.writer, writeClient: config.settings.writeClient, + connectionErrorListener: config.settings.connectionErrorListener, }, logger, ); diff --git a/packages/server/destinations/gcp/src/bigquery/push.ts b/packages/server/destinations/gcp/src/bigquery/push.ts index 0efce57f0..b17ec236b 100644 --- a/packages/server/destinations/gcp/src/bigquery/push.ts +++ b/packages/server/destinations/gcp/src/bigquery/push.ts @@ -1,6 +1,7 @@ import type { PushFn } from './types'; import { isObject } from '@walkeros/core'; import { eventToRow } from './eventToRow'; +import { ensureWriter } from './writer'; export const push: PushFn = async function ( event, @@ -8,6 +9,18 @@ export const push: PushFn = async function ( ) { const settings = config.settings; if (!settings) return logger.throw('settings missing, init() not run'); + + if (!settings.writer) + return logger.throw('writer is missing, init() not run'); + + // Self-heal a writer that the connection's `'error'` handler flagged broken: + // exactly one re-open attempt before failing. On a failed re-open this throws, + // routing the event to the DLQ in-band (instead of an out-of-band crash) and + // feeding the collector's per-destination breaker. Breaker-gated: the + // collector does not call push while the breaker is OPEN, so the re-open runs + // at most once per admitted push, never in a loop. + if (settings.writerBroken) await ensureWriter(settings, logger); + const { writer, datasetId, tableId } = settings; if (!writer) return logger.throw('writer is missing, init() not run'); diff --git a/packages/server/destinations/gcp/src/bigquery/pushBatch.ts b/packages/server/destinations/gcp/src/bigquery/pushBatch.ts index 1831e0801..04fe2676d 100644 --- a/packages/server/destinations/gcp/src/bigquery/pushBatch.ts +++ b/packages/server/destinations/gcp/src/bigquery/pushBatch.ts @@ -1,6 +1,7 @@ import type { PushBatchFn } from './types'; import { isObject } from '@walkeros/core'; import { eventToRow } from './eventToRow'; +import { ensureWriter } from './writer'; /** * Batched push using a single appendRows call. @@ -15,6 +16,16 @@ import { eventToRow } from './eventToRow'; export const pushBatch: PushBatchFn = async (batch, { config, logger }) => { const settings = config.settings; if (!settings) return logger.throw('settings missing, init() not run'); + + if (!settings.writer) + return logger.throw('writer is missing, init() not run'); + + // Self-heal a broken writer (one re-open attempt) before failing. On a failed + // re-open this throws, routing the WHOLE batch to the DLQ in-band and feeding + // the collector's per-destination breaker. Breaker-gated by the collector, so + // the re-open runs at most once per admitted batch. + if (settings.writerBroken) await ensureWriter(settings, logger); + const { writer, datasetId, tableId } = settings; if (!writer) return logger.throw('writer is missing, init() not run'); diff --git a/packages/server/destinations/gcp/src/bigquery/types/index.ts b/packages/server/destinations/gcp/src/bigquery/types/index.ts index 429c2c43c..2f38b895c 100644 --- a/packages/server/destinations/gcp/src/bigquery/types/index.ts +++ b/packages/server/destinations/gcp/src/bigquery/types/index.ts @@ -8,6 +8,11 @@ import type { } from '@walkeros/core'; import type { BigQuery, BigQueryOptions } from '@google-cloud/bigquery'; import type { managedwriter } from '@google-cloud/bigquery-storage'; +import type { + WriterHandles, + StreamConnection as WriterConnection, + RemoveListener as WriterRemoveListener, +} from '../writer'; export interface Settings { client: BigQuery; @@ -19,6 +24,34 @@ export interface Settings { // Runtime-only handles populated by init(); not user-facing. writeClient?: managedwriter.WriterClient; writer?: managedwriter.JSONWriter; + // The StreamConnection the writer appends to. Held so the connection-error + // listener can be removed on re-open/destroy. Runtime-only. + connection?: WriterConnection; + // The `{ off }` disposable for the connection-error listener. Runtime-only. + connectionErrorListener?: WriterRemoveListener; + /** + * Set by the connection's `'error'` handler when the long-lived stream + * errored out-of-band. The next push self-heals (one re-open attempt) before + * failing; while set, push/pushBatch route the event to the DLQ. Runtime-only. + */ + writerBroken?: boolean; + /** The last out-of-band stream error, surfaced in the DLQ-routed message. Runtime-only. */ + lastStreamError?: Error; + /** + * Lazy re-open hook, wired by init(). Closes over the openWriter args plus + * `reportError`/logger so the push path can self-heal a broken writer without + * the destination needing the original init context. Runtime-only. + */ + reopenWriter?: () => Promise; + /** + * The in-flight self-heal re-open, memoized so concurrent pushes admitted in + * the same breaking pass (the collector fans out a destination's events with + * Promise.all) await ONE re-open instead of each closing+re-opening, which + * would orphan a gRPC connection + live 'error' listener per redundant + * attempt. Cleared in a finally so a later push after a failed re-open + * retries. Runtime-only. + */ + reopenInFlight?: Promise; } export interface InitSettings { @@ -31,6 +64,12 @@ export interface InitSettings { // Runtime-only handles populated by init(); not user-facing. writeClient?: managedwriter.WriterClient; writer?: managedwriter.JSONWriter; + connection?: WriterConnection; + connectionErrorListener?: WriterRemoveListener; + writerBroken?: boolean; + lastStreamError?: Error; + reopenWriter?: () => Promise; + reopenInFlight?: Promise; } export interface Mapping {} diff --git a/packages/server/destinations/gcp/src/bigquery/writer.ts b/packages/server/destinations/gcp/src/bigquery/writer.ts index 7abbfeeee..afbcdfbfb 100644 --- a/packages/server/destinations/gcp/src/bigquery/writer.ts +++ b/packages/server/destinations/gcp/src/bigquery/writer.ts @@ -9,6 +9,19 @@ type CallOptions = NonNullable< Parameters[1] >; +// The StreamConnection instance returned by createStreamConnection. It is an +// EventEmitter, so it exposes the typed `onConnectionError` hook we attach to +// (and the bare `on('error', …)` it inherits). Derived from the SDK method +// signature so we don't import the un-exported StreamConnection class. +export type StreamConnection = Awaited< + ReturnType +>; + +// The `{ off }` disposable returned by onConnectionError, used to remove the +// listener in closeWriter. Derived from the method's return type (not exported +// from the package root). +export type RemoveListener = ReturnType; + export interface OpenWriterArgs { projectId: string; datasetId: string; @@ -24,11 +37,25 @@ export interface OpenWriterArgs { * detached. */ timeout?: number; + /** + * Handler for the connection's out-of-band `'error'` event. Attaching it (by + * mere presence) prevents Node's uncaught-`'error'` throw on the detached + * gRPC tick that crashed the process; the destination wires it to flag the + * writer broken and route the error through `context.reportError`. MUST NOT + * throw (it runs on a detached emitter tick). + */ + onConnectionError?: (err: unknown) => void; } export interface WriterHandles { writeClient: managedwriter.WriterClient; writer: managedwriter.JSONWriter; + // The StreamConnection the writer appends to. Held so closeWriter can remove + // the connection-error listener it owns. + connection: StreamConnection; + // The `{ off }` disposable for the connection-error listener, removed in + // closeWriter so a re-opened writer doesn't accumulate stale listeners. + connectionErrorListener?: RemoveListener; } /** @@ -38,15 +65,24 @@ export interface WriterHandles { * Sequence (per SDK docs and empirical SDK probe): * 1. new WriterClient * 2. createStreamConnection - * 3. getWriteStream(view: FULL) to retrieve the table schema - * 4. adapt.convertStorageSchemaToProto2Descriptor → protoDescriptor - * 5. new JSONWriter({ connection, protoDescriptor }) + * 3. attach the connection-error listener (so a detached stream `'error'` + * is contained instead of crashing the process) + * 4. getWriteStream(view: FULL) to retrieve the table schema + * 5. adapt.convertStorageSchemaToProto2Descriptor → protoDescriptor + * 6. new JSONWriter({ connection, protoDescriptor }) */ export async function openWriter( args: OpenWriterArgs, logger: Logger.Instance, ): Promise { - const { projectId, datasetId, tableId, bigquery, timeout } = args; + const { + projectId, + datasetId, + tableId, + bigquery, + timeout, + onConnectionError, + } = args; const destinationTable = `projects/${projectId}/datasets/${datasetId}/tables/${tableId}`; logger.debug('Opening BigQuery Storage Write API writer', { @@ -65,18 +101,35 @@ export async function openWriter( projectId, ...bigquery, }); + let connectionErrorListener: RemoveListener | undefined; + let connection: StreamConnection | undefined; try { // Use streamId (not streamType) so the SDK resolves to the table's // implicit `_default` stream without calling CreateWriteStream. Passing // managedwriter.DefaultStream as streamType triggers a CreateWriteStream // call with type='DEFAULT', which BQ rejects as TYPE_UNSPECIFIED. - const connection = await writeClient.createStreamConnection( + connection = await writeClient.createStreamConnection( { destinationTable, streamId: managedwriter.DefaultStream, }, callOptions, ); + + // Attach the connection-error listener on the StreamConnection (NOT the + // inner gRPC `_connection`) BEFORE building the JSONWriter, so any `'error'` + // the connection emits has a listener from the first tick onward. Without a + // listener, Node throws the emitted error as an uncaughtException on a + // detached tick, bypassing the collector's promise-path try/catch (the + // crash this handler exists to prevent). `onConnectionError` is the SDK's + // documented surface and returns an `{ off }` disposable; it adds an + // `'error'` listener under the hood. + if (onConnectionError) { + connectionErrorListener = connection.onConnectionError((err) => { + onConnectionError(err); + }); + } + const streamId = connection.getStreamId(); const writeStream = await writeClient.getWriteStream( { @@ -99,20 +152,131 @@ export async function openWriter( protoDescriptor, }); - return { writeClient, writer }; + return { writeClient, writer, connection, connectionErrorListener }; } catch (err) { // Release any resources already opened by the partial init so we don't - // leak gRPC handles. closeWriter swallows close errors and only logs. - closeWriter({ writeClient }, logger); + // leak gRPC handles (including the connection-error listener). closeWriter + // swallows close errors and only logs. + closeWriter({ writeClient, connectionErrorListener }, logger); throw err; } } +// Structural subset of Settings that ensureWriter reads/writes. Kept local so +// writer.ts doesn't import the full Settings type (which imports back from here). +export interface EnsureWriterSettings { + writer?: managedwriter.JSONWriter; + writeClient?: managedwriter.WriterClient; + connection?: StreamConnection; + connectionErrorListener?: RemoveListener; + writerBroken?: boolean; + lastStreamError?: Error; + reopenWriter?: () => Promise; + reopenInFlight?: Promise; +} + +/** + * Single owner of the writer lifecycle on the push path. When the connection's + * `'error'` handler has flagged the writer broken, attempt a lazy re-open + * before failing: + * - success: swap in the fresh handles, clear the broken flag, proceed. + * - failure: stay broken, throw so the event is DLQ-routed. + * + * Self-heal is bounded along two independent axes: + * + * 1. ACROSS sequential passes, by the collector's breaker WITHOUT importing + * `@walkeros/collector`: the skip gate does not call push while the breaker + * is OPEN, admits exactly one probe when HALF-OPEN, and each failed re-open + * throws (feeding the breaker's transport-failure accounting until it opens). + * So once the breaker trips, no further re-opens are attempted. + * + * 2. WITHIN one pass, by the `reopenInFlight` memo. The breaker only opens + * AFTER a pass records its failures, so in the first (most concurrent) pass + * the breaker is still CLOSED and the collector fans the destination's + * admitted events out with Promise.all. Without the memo, every concurrent + * push would pass the `writerBroken` guard and each run its own + * closeWriter+reopen, orphaning a gRPC connection + live 'error' listener + * per redundant attempt. The memo collapses a concurrent burst into ONE + * re-open that every caller awaits; a shared failure rejects into all of + * them (each DLQ-routes correctly), and the memo clears in a finally so a + * later push retries. + * + * The single openWriter carries the gax CallOptions.timeout, so each attempt is + * time-bounded. pushBatch shares this function, so it inherits both bounds. + */ +export function ensureWriter( + settings: EnsureWriterSettings, + logger: Logger.Instance, +): Promise { + if (!settings.writerBroken) return Promise.resolve(); + + // A concurrent caller already started the re-open: join it instead of opening + // a second connection (which would orphan one of them). + if (settings.reopenInFlight) return settings.reopenInFlight; + + const lastError = settings.lastStreamError; + logger.info('BigQuery writer broken; attempting one re-open before failing', { + error: lastError ? lastError.message : 'unknown stream error', + }); + + const reopen = settings.reopenWriter; + if (!reopen) { + // No re-open hook (init never wired it): cannot self-heal, stay broken. + return Promise.reject( + new Error( + 'BigQuery writer is broken and no re-open hook is configured: ' + + (lastError ? lastError.message : 'unknown stream error'), + ), + ); + } + + const inFlight = (async () => { + try { + // Release the broken handles (incl. its connection-error listener) before + // re-opening so we don't leak the old gRPC connection. Only the one + // caller that started the memo runs this, so the old handles are closed + // exactly once. + closeWriter( + { + writer: settings.writer, + writeClient: settings.writeClient, + connectionErrorListener: settings.connectionErrorListener, + }, + logger, + ); + + const handles = await reopen(); + settings.writeClient = handles.writeClient; + settings.writer = handles.writer; + settings.connection = handles.connection; + settings.connectionErrorListener = handles.connectionErrorListener; + settings.writerBroken = false; + settings.lastStreamError = undefined; + logger.info('BigQuery writer re-opened after a stream error'); + } finally { + // Clear the memo so a later push after a FAILED re-open retries (on a + // successful re-open writerBroken is already false, so the next push skips + // ensureWriter entirely). + settings.reopenInFlight = undefined; + } + })(); + + settings.reopenInFlight = inFlight; + return inFlight; +} + /** Close handles in safe order. Errors are logged, never thrown (called from destroy). */ export function closeWriter( handles: Partial, logger: Logger.Instance, ): void { + try { + handles.connectionErrorListener?.off(); + } catch (err) { + logger.warn('connection error listener removal failed', { + error: String(err), + }); + } try { handles.writer?.close(); } catch (err) { diff --git a/packages/server/sources/express/src/__tests__/index.test.ts b/packages/server/sources/express/src/__tests__/index.test.ts index 26993a790..a050e2dc7 100644 --- a/packages/server/sources/express/src/__tests__/index.test.ts +++ b/packages/server/sources/express/src/__tests__/index.test.ts @@ -17,6 +17,7 @@ function createSourceContext( logger: env.logger || createMockLogger(), id: 'test-express', collector: {} as Collector.Instance, + reportError: () => undefined, // Minimal withScope stub: forwards body with a scope env that delegates // push back to env.push so the test's mockPush still captures the call. withScope: async (_raw, respond, body) => { @@ -904,5 +905,99 @@ describe('sourceExpress', () => { expect(res.responseBody).toMatchObject({ success: true }); expect(sendSpy).not.toHaveBeenCalled(); }); + + it('POST async responds while push is still pending (ordering proof)', async () => { + const order: string[] = []; + const { push, resolve } = createDeferredPush(); + // Record the relative moment the destination push settles. + const settled = push().then(() => { + order.push('push-settled'); + }); + + const source = await sourceExpress( + createSourceContext( + {}, + { + push: push as never, + command: mockCommand as never, + elb: jest.fn() as never, + logger: createMockLogger(), + }, + ), + ); + + const res = createMockResponse(); + const jsonSpy = res.json as jest.Mock; + jsonSpy.mockImplementation((body: unknown) => { + order.push('respond'); + res.responseBody = body; + return res; + }); + + const req = createMockRequest({ + method: 'POST', + body: { event: 'page view' }, + }); + + await source.push(req, res); + + // Response is committed even though the push has not settled yet. + expect(order).toEqual(['respond']); + expect(res.statusCode).toBe(200); + expect(res.responseBody).toMatchObject({ success: true }); + + // Now let delivery finish; it lands strictly after the response. + resolve(); + await settled; + expect(order).toEqual(['respond', 'push-settled']); + }); + + it('POST async rejected push never escapes as an unhandled rejection', async () => { + const { push, reject } = createDeferredPush(); + const logger = createMockLogger(); + const source = await sourceExpress( + createSourceContext( + {}, + { + push: push as never, + command: mockCommand as never, + elb: jest.fn() as never, + logger, + }, + ), + ); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + + try { + const req = createMockRequest({ + method: 'POST', + body: { event: 'page view' }, + }); + const res = createMockResponse(); + + await source.push(req, res); + expect(res.statusCode).toBe(200); + expect(res.responseBody).toMatchObject({ success: true }); + + const error = new Error('delivery failed'); + reject(error); + + // Flush microtasks AND a macrotask so Node would emit + // 'unhandledRejection' for any promise the source failed to catch. + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolveTick) => setTimeout(resolveTick, 0)); + + expect(logger.error).toHaveBeenCalledWith(error); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); }); }); diff --git a/skills/walkeros-understanding-destinations/SKILL.md b/skills/walkeros-understanding-destinations/SKILL.md index 58ecb1c66..2dd8611cd 100644 --- a/skills/walkeros-understanding-destinations/SKILL.md +++ b/skills/walkeros-understanding-destinations/SKILL.md @@ -143,6 +143,31 @@ Counters (`count`, `out`) are bumped only after a successful flush. Operators also see `status.destinations[id].inFlightBatch`: the number of events buffered but not yet delivered. +### Out-of-band errors from an EventEmitter SDK + +`tryCatchAsync` only catches errors on the awaited `push`/`pushBatch` path. If a +destination owns an SDK object that is an EventEmitter (a BigQuery +`StreamConnection`, a Redis client, a Kafka producer), that object can emit +`'error'` on a detached tick with no awaiter. An EventEmitter that emits +`'error'` with zero listeners throws synchronously and crashes the process. + +A destination that owns such an object MUST attach an `'error'` listener and +route the error through `context.reportError`, which is available on every step +context: + +- `context.reportError(err)` (no event): a connection-level error between + pushes. Logs (redacted) and bumps `status.connectionErrors[stepId]`. Does not + count as a failed event. +- `context.reportError(err, event)`: a specific event was lost. Routes it to the + DLQ and bumps `failed`, exactly like an in-band push failure. + +`reportError` is guarded and must never be called in a way that throws back into +the emitter tick. The Pub/Sub pull source (`sources/gcp/src/pubsub/pull`) is the +reference for attaching an `'error'` listener. The runner's process guards are a +backstop, but the listener is what gives clean DLQ routing, attribution, and +redaction. Optionally pair this with the `breaker` config so a persistently +broken transport stops retrying every event. + ## Require vs Consent Two separate mechanisms control when destinations receive events: From 0a8a08b14d97cbba03d08d29dfca864a7713e75c Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Tue, 16 Jun 2026 15:08:19 +0200 Subject: [PATCH 2/9] async --- .changeset/source-config-async.md | 9 +++++++++ packages/core/src/schemas/source.ts | 6 ++++++ packages/core/src/types/source.ts | 14 ++++++++++++++ .../express/src/__tests__/cache-roundtrip.test.ts | 9 ++++++--- .../sources/express/src/__tests__/index.test.ts | 6 ++---- packages/server/sources/express/src/index.ts | 10 ++++++---- .../server/sources/express/src/schemas/settings.ts | 7 ------- packages/server/sources/express/src/types.ts | 4 ++-- 8 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 .changeset/source-config-async.md diff --git a/.changeset/source-config-async.md b/.changeset/source-config-async.md new file mode 100644 index 000000000..f33162ed1 --- /dev/null +++ b/.changeset/source-config-async.md @@ -0,0 +1,9 @@ +--- +'@walkeros/core': patch +'@walkeros/server-source-express': patch +--- + +Add an optional `async` option to the source config (`Source.Config.async`) for +respond-first acknowledgement on response-producing server sources. The express +source now reads `config.async` (default `true`): a 2xx response means the event +was accepted, not yet delivered. diff --git a/packages/core/src/schemas/source.ts b/packages/core/src/schemas/source.ts index f4df07fc2..748c33973 100644 --- a/packages/core/src/schemas/source.ts +++ b/packages/core/src/schemas/source.ts @@ -151,6 +151,12 @@ export const ConfigSchema = MappingConfigSchema.extend({ logger: LoggerConfigSchema.optional().describe( 'Logger configuration (level, handler) to override the collector defaults', ), + async: z + .boolean() + .optional() + .describe( + 'Respond-first acknowledgement for response-producing server sources (express today; future fetch/lambda). When true (the default for such sources) the source responds 2xx ("accepted") before the event is delivered; when false it waits for delivery to settle. Browser/dataLayer sources have no HTTP response to defer and ignore it. A 2xx means accepted, not delivered. Default is per source type.', + ), setup: z .union([z.boolean(), z.record(z.string(), z.unknown())]) .optional() diff --git a/packages/core/src/types/source.ts b/packages/core/src/types/source.ts index 8e2ebf8b9..175e81716 100644 --- a/packages/core/src/types/source.ts +++ b/packages/core/src/types/source.ts @@ -102,6 +102,20 @@ export interface Config< id?: string; /** Logger configuration (level, handler) to override the collector's defaults. */ logger?: Logger.Config; + /** + * Respond-first acknowledgement for response-producing server sources. + * + * When a source produces an HTTP response (express today; future fetch / + * lambda), `async: true` (the default for such sources) responds 2xx + * ("accepted") before the event is delivered to the collector, so the + * client is not blocked on backend delivery. `async: false` waits for + * delivery to settle before responding. A 2xx means "accepted", not + * "delivered". + * + * Browser and dataLayer sources have no HTTP response to defer and ignore + * this flag. The default is per source type. + */ + async?: boolean; /** Mark as primary source; its push function becomes the exported `elb` from startFlow. */ primary?: boolean; /** Defer source initialization until these collector events fire (e.g., `['consent']`). */ diff --git a/packages/server/sources/express/src/__tests__/cache-roundtrip.test.ts b/packages/server/sources/express/src/__tests__/cache-roundtrip.test.ts index 43e406679..1e80eb77b 100644 --- a/packages/server/sources/express/src/__tests__/cache-roundtrip.test.ts +++ b/packages/server/sources/express/src/__tests__/cache-roundtrip.test.ts @@ -61,7 +61,8 @@ describe('Express source cache round-trip', () => { // GIF fallback applies (the default respond-first pixel mode would // win the race with the GIF). config: { - settings: { paths: ['/walker.js'], async: false }, + settings: { paths: ['/walker.js'] }, + async: false, ingest: { map: { method: { key: 'method' }, @@ -199,7 +200,8 @@ describe('Express source cache round-trip', () => { express: { code: sourceExpress, config: { - settings: { paths: ['/walker.js'], async: false }, + settings: { paths: ['/walker.js'] }, + async: false, ingest: { map: { method: { key: 'method' }, @@ -328,7 +330,8 @@ describe('Express source cache round-trip', () => { express: { code: sourceExpress, config: { - settings: { paths: ['/asset'], async: false }, + settings: { paths: ['/asset'] }, + async: false, ingest: { map: { method: { key: 'method' }, path: { key: 'url' } }, }, diff --git a/packages/server/sources/express/src/__tests__/index.test.ts b/packages/server/sources/express/src/__tests__/index.test.ts index a050e2dc7..e99884dfc 100644 --- a/packages/server/sources/express/src/__tests__/index.test.ts +++ b/packages/server/sources/express/src/__tests__/index.test.ts @@ -121,7 +121,6 @@ describe('sourceExpress', () => { expect(source.config.settings).toEqual({ paths: ['/collect'], cors: true, - async: true, }); expect(typeof source.push).toBe('function'); expect(source.app).toBeDefined(); @@ -149,7 +148,6 @@ describe('sourceExpress', () => { expect(source.config.settings).toEqual({ paths: ['/events'], cors: false, - async: true, }); }); @@ -394,7 +392,7 @@ describe('sourceExpress', () => { const source = await sourceExpress( createSourceContext( - { settings: { async: false } }, + { async: false }, { push: errorPush as never, command: mockCommand as never, @@ -873,7 +871,7 @@ describe('sourceExpress', () => { }); const source = await sourceExpress( createSourceContext( - { settings: { async: false } }, + { async: false }, { push: slowPush as never, command: mockCommand as never, diff --git a/packages/server/sources/express/src/index.ts b/packages/server/sources/express/src/index.ts index ab92a1634..effb423d1 100644 --- a/packages/server/sources/express/src/index.ts +++ b/packages/server/sources/express/src/index.ts @@ -38,13 +38,15 @@ export const sourceExpress = async ( const settings = { ...userSettings, cors: userSettings.cors ?? true, - // Respond-first by default: a 2xx means "accepted", not "delivered". - async: userSettings.async ?? true, paths: userSettings.paths ?? (userSettings.path ? [userSettings.path] : ['/collect']), }; + // Respond-first by default: a 2xx means "accepted", not "delivered". + // Standardized on the source config (Source.Config.async), not settings. + const respondFirst = config.async ?? true; + const app = expressLib(); // Body parsing — JSON content-type plus text/plain so navigator.sendBeacon @@ -119,7 +121,7 @@ export const sourceExpress = async ( }); if (parsedData && typeof parsedData === 'object') { - if (settings.async) { + if (respondFirst) { // Respond-first: the tracking pixel must return instantly and // never block on backend delivery. Fire the push without // awaiting; a rejected push is logged (destination errors are @@ -147,7 +149,7 @@ export const sourceExpress = async ( const eventData = req.body && typeof req.body === 'object' ? req.body : {}; - if (settings.async) { + if (respondFirst) { // Respond-first ("accepted"), then deliver asynchronously. A // rejected push is logged, not surfaced to the client and not left // unhandled (destination errors are DLQ'd inside the collector). diff --git a/packages/server/sources/express/src/schemas/settings.ts b/packages/server/sources/express/src/schemas/settings.ts index c73747af3..7842a0d6f 100644 --- a/packages/server/sources/express/src/schemas/settings.ts +++ b/packages/server/sources/express/src/schemas/settings.ts @@ -32,13 +32,6 @@ export const SettingsSchema = z.object({ 'CORS configuration: false = disabled, true = allow all origins (default), object = custom configuration', ) .default(true), - - async: z - .boolean() - .describe( - 'Respond-first delivery (default true). When true, POST responds 2xx ("accepted") immediately and pushes to the collector without blocking the response; a rejected push is logged (destination errors are DLQ\'d inside the collector). When false, the response waits for the push to settle. The GET pixel always responds first regardless of this flag. A 2xx means accepted, not delivered.', - ) - .default(true), }); export type Settings = z.infer; diff --git a/packages/server/sources/express/src/types.ts b/packages/server/sources/express/src/types.ts index 59a597b5f..febc0ca77 100644 --- a/packages/server/sources/express/src/types.ts +++ b/packages/server/sources/express/src/types.ts @@ -29,11 +29,11 @@ export interface Mapping { // Express-specific push type (uses Express Request/Response types) // // Ack contract: a 2xx response means the event was *accepted*, not that it was -// *delivered*. With `settings.async` (the default, `true`) the handler responds +// *delivered*. With `config.async` (the default, `true`) the handler responds // first and pushes to the collector without blocking the response; rejected // pushes are logged and destination errors are DLQ'd inside the collector. The // GET tracking pixel always responds first regardless of the flag. Set -// `settings.async: false` to make the response wait for delivery to settle. +// `config.async: false` to make the response wait for delivery to settle. export type Push = (req: Request, res: Response) => Promise; export interface Env extends CoreSource.Env { From d8aebd1ed8daf48461c5dafe0074df4ae36dfc39 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 17 Jun 2026 09:50:24 +0200 Subject: [PATCH 3/9] tele --- .changeset/collector-inevent-telemetry.md | 8 + packages/cli/openapi/spec.json | 3 + .../validate/__tests__/flow-routes.test.ts | 56 +++++++ packages/cli/src/types/api.gen.d.ts | 1 + .../__tests__/destination.observer.test.ts | 140 +++++++++++++++++- .../src/__tests__/push.observer.test.ts | 56 ++++++- .../__tests__/transformer.observer.test.ts | 97 +++++++++++- packages/collector/src/destination.ts | 6 +- packages/collector/src/push.ts | 19 ++- packages/collector/src/transformer.ts | 1 + 10 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 .changeset/collector-inevent-telemetry.md diff --git a/.changeset/collector-inevent-telemetry.md b/.changeset/collector-inevent-telemetry.md new file mode 100644 index 000000000..601e6204d --- /dev/null +++ b/.changeset/collector-inevent-telemetry.md @@ -0,0 +1,8 @@ +--- +'@walkeros/collector': patch +--- + +Trace-level telemetry now carries the inbound event on every pipeline hop, so +per-step observers can show what each collector, transformer, and destination +actually received. The destination's outbound frame now reports the delivered +event as its payload, and the raw delivery response moves to `meta.response`. diff --git a/packages/cli/openapi/spec.json b/packages/cli/openapi/spec.json index 9d5f7fdc5..41406f999 100644 --- a/packages/cli/openapi/spec.json +++ b/packages/cli/openapi/spec.json @@ -2255,6 +2255,9 @@ "settingsName": { "type": "string", "minLength": 1 + }, + "force": { + "type": "boolean" } }, "required": ["settingsName"] diff --git a/packages/cli/src/commands/validate/__tests__/flow-routes.test.ts b/packages/cli/src/commands/validate/__tests__/flow-routes.test.ts index 238cfa44a..8d2062bb3 100644 --- a/packages/cli/src/commands/validate/__tests__/flow-routes.test.ts +++ b/packages/cli/src/commands/validate/__tests__/flow-routes.test.ts @@ -423,3 +423,59 @@ describe('validateFlow — many operator lint warnings', () => { expect(manyWarnings).toEqual([]); }); }); + +describe('validateFlow — mandatory config.platform', () => { + it('rejects a flow whose config is missing platform', () => { + // `config.platform` is the single deterministic source of a flow's + // platform; a missing value must be a loud error, never a guess. A config + // block that is present but omits `platform` must fail validation through + // the full validate path (core Zod schema), not pass silently. + const flow = { + version: 4, + flows: { + default: { + config: { url: 'https://example.com' }, + sources: { + browser: { + package: '@walkeros/web-source-browser', + }, + }, + }, + }, + }; + + const result = validateFlow(flow); + + expect(result.valid).toBe(false); + const platformError = result.errors.find( + (e) => + e.code === 'SCHEMA_VALIDATION' && + /platform/i.test(e.message + ' ' + e.path), + ); + expect(platformError).toBeDefined(); + }); + + it('accepts a flow whose config declares a valid platform', () => { + // Positive control: an otherwise identical flow with `config.platform` + // present produces no platform-related schema error. + const flow = { + version: 4, + flows: { + default: { + config: { platform: 'web' as const, url: 'https://example.com' }, + sources: { + browser: { + package: '@walkeros/web-source-browser', + }, + }, + }, + }, + }; + + const result = validateFlow(flow); + + expect( + result.errors.some((e) => /platform/i.test(e.message + ' ' + e.path)), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/types/api.gen.d.ts b/packages/cli/src/types/api.gen.d.ts index b89748216..7a03c78ef 100644 --- a/packages/cli/src/types/api.gen.d.ts +++ b/packages/cli/src/types/api.gen.d.ts @@ -8488,6 +8488,7 @@ export interface components { } | null; CreateObserveSessionRequest: { settingsName: string; + force?: boolean; }; ObserveSessionHeartbeatResponse: { /** @enum {boolean} */ diff --git a/packages/collector/src/__tests__/destination.observer.test.ts b/packages/collector/src/__tests__/destination.observer.test.ts index 75b574ef2..ce8867d1e 100644 --- a/packages/collector/src/__tests__/destination.observer.test.ts +++ b/packages/collector/src/__tests__/destination.observer.test.ts @@ -1,4 +1,5 @@ -import type { FlowState } from '@walkeros/core'; +import type { FlowState, WalkerOS } from '@walkeros/core'; +import { createTelemetryObserver } from '@walkeros/core'; import { startFlow } from '..'; describe('destination self-emission', () => { @@ -121,4 +122,141 @@ describe('destination self-emission', () => { jest.useRealTimers(); } }); + + test('trace observer carries inEvent on destination in frame and outEvent (delivered event) plus meta.response on out frame', async () => { + const states: FlowState[] = []; + let received: WalkerOS.Event | undefined; + const response = { ok: true, id: 'resp-123' }; + + const { collector, elb } = await startFlow({ + run: true, + destinations: { + gtag: { + code: { + type: 'gtag', + config: {}, + push: async (event) => { + received = event; + return response; + }, + }, + }, + }, + }); + collector.observers.add( + createTelemetryObserver((state) => states.push(state), { + flowId: 'default', + level: 'trace', + }), + ); + + await elb({ name: 'page view', data: {} }); + + expect(received).toBeDefined(); + + const inFrame = states.find( + (s) => + s.stepType === 'destination' && + s.stepId === 'destination.gtag' && + s.phase === 'in', + ); + expect(inFrame).toBeDefined(); + expect(inFrame?.inEvent).toEqual(received); + + const outFrame = states.find( + (s) => + s.stepType === 'destination' && + s.stepId === 'destination.gtag' && + s.phase === 'out', + ); + expect(outFrame).toBeDefined(); + // outEvent is the DELIVERED EVENT, not the API response. + expect(outFrame?.outEvent).toEqual(received); + // The raw API response moves to meta.response. + expect(outFrame?.meta?.response).toEqual(response); + }); + + test('void-returning destination keeps outEvent as the event and attaches no response meta', async () => { + const states: FlowState[] = []; + let received: WalkerOS.Event | undefined; + + const { collector, elb } = await startFlow({ + run: true, + destinations: { + gtag: { + code: { + type: 'gtag', + config: {}, + push: async (event) => { + received = event; + }, + }, + }, + }, + }); + collector.observers.add( + createTelemetryObserver((state) => states.push(state), { + flowId: 'default', + level: 'trace', + }), + ); + + await elb({ name: 'page view', data: {} }); + + const outFrame = states.find( + (s) => + s.stepType === 'destination' && + s.stepId === 'destination.gtag' && + s.phase === 'out', + ); + expect(outFrame).toBeDefined(); + expect(outFrame?.outEvent).toEqual(received); + expect(outFrame?.meta?.response).toBeUndefined(); + }); + + test('standard observer strips inEvent/outEvent but keeps meta.response on the destination out frame', async () => { + const states: FlowState[] = []; + const response = { ok: true, id: 'resp-456' }; + + const { collector, elb } = await startFlow({ + run: true, + destinations: { + gtag: { + code: { + type: 'gtag', + config: {}, + push: async () => response, + }, + }, + }, + }); + collector.observers.add( + createTelemetryObserver((state) => states.push(state), { + flowId: 'default', + level: 'standard', + }), + ); + + await elb({ name: 'page view', data: {} }); + + const inFrame = states.find( + (s) => + s.stepType === 'destination' && + s.stepId === 'destination.gtag' && + s.phase === 'in', + ); + expect(inFrame).toBeDefined(); + expect(inFrame?.inEvent).toBeUndefined(); + + const outFrame = states.find( + (s) => + s.stepType === 'destination' && + s.stepId === 'destination.gtag' && + s.phase === 'out', + ); + expect(outFrame).toBeDefined(); + // Trace-gated payload stripped, but meta survives the projector. + expect(outFrame?.outEvent).toBeUndefined(); + expect(outFrame?.meta?.response).toEqual(response); + }); }); diff --git a/packages/collector/src/__tests__/push.observer.test.ts b/packages/collector/src/__tests__/push.observer.test.ts index 0f844d6ae..68e9070a5 100644 --- a/packages/collector/src/__tests__/push.observer.test.ts +++ b/packages/collector/src/__tests__/push.observer.test.ts @@ -1,4 +1,5 @@ -import type { FlowState } from '@walkeros/core'; +import type { FlowState, WalkerOS } from '@walkeros/core'; +import { createTelemetryObserver } from '@walkeros/core'; import { startFlow } from '..'; describe('collector.push self-emission', () => { @@ -52,4 +53,57 @@ describe('collector.push self-emission', () => { expect(destError).toBeDefined(); expect(destError?.error?.message).toContain('destination kaboom'); }); + + test('trace observer keeps inEvent on the collector.push in frame', async () => { + const states: FlowState[] = []; + + const { collector, elb } = await startFlow({ + run: true, + destinations: {}, + }); + collector.observers.add( + createTelemetryObserver((state) => states.push(state), { + flowId: 'default', + level: 'trace', + }), + ); + + const inbound: WalkerOS.DeepPartialEvent = { name: 'page view', data: {} }; + await elb(inbound); + + const inFrame = states.find( + (s) => + s.stepId === 'collector.push' && + s.stepType === 'collector' && + s.phase === 'in', + ); + expect(inFrame).toBeDefined(); + expect(inFrame?.inEvent).toEqual(inbound); + }); + + test('standard observer strips inEvent from the collector.push in frame', async () => { + const states: FlowState[] = []; + + const { collector, elb } = await startFlow({ + run: true, + destinations: {}, + }); + collector.observers.add( + createTelemetryObserver((state) => states.push(state), { + flowId: 'default', + level: 'standard', + }), + ); + + await elb({ name: 'page view', data: {} }); + + const inFrame = states.find( + (s) => + s.stepId === 'collector.push' && + s.stepType === 'collector' && + s.phase === 'in', + ); + expect(inFrame).toBeDefined(); + expect(inFrame?.inEvent).toBeUndefined(); + }); }); diff --git a/packages/collector/src/__tests__/transformer.observer.test.ts b/packages/collector/src/__tests__/transformer.observer.test.ts index 1ae2cacbf..b2cb848bd 100644 --- a/packages/collector/src/__tests__/transformer.observer.test.ts +++ b/packages/collector/src/__tests__/transformer.observer.test.ts @@ -1,4 +1,5 @@ -import type { FlowState } from '@walkeros/core'; +import type { FlowState, WalkerOS } from '@walkeros/core'; +import { createTelemetryObserver } from '@walkeros/core'; import { startFlow } from '..'; describe('transformer.push self-emission', () => { @@ -70,4 +71,98 @@ describe('transformer.push self-emission', () => { expect(err).toBeDefined(); expect(err?.error?.message).toContain('transformer kaboom'); }); + + test('trace observer keeps inEvent/outEvent on the transformer frames', async () => { + const states: FlowState[] = []; + let seenEvent: WalkerOS.DeepPartialEvent | undefined; + + const { collector, elb } = await startFlow({ + run: true, + transformers: { + echo: { + code: async (context) => ({ + type: 'echo', + config: context.config, + push: async (event) => { + seenEvent = event; + return { event }; + }, + }), + }, + }, + destinations: { + sink: { + code: { type: 'sink', config: {}, push: async () => undefined }, + before: 'echo', + }, + }, + }); + collector.observers.add( + createTelemetryObserver((state) => states.push(state), { + flowId: 'default', + level: 'trace', + }), + ); + + await elb({ name: 'page view', data: {} }); + + const inFrame = states.find( + (s) => + s.stepType === 'transformer' && + s.stepId === 'transformer.echo' && + s.phase === 'in', + ); + expect(inFrame).toBeDefined(); + expect(seenEvent).toBeDefined(); + expect(inFrame?.inEvent).toEqual(seenEvent); + + const outFrame = states.find( + (s) => + s.stepType === 'transformer' && + s.stepId === 'transformer.echo' && + s.phase === 'out', + ); + expect(outFrame).toBeDefined(); + expect(outFrame?.outEvent).toEqual({ event: seenEvent }); + }); + + test('standard observer strips inEvent from the transformer in frame', async () => { + const states: FlowState[] = []; + + const { collector, elb } = await startFlow({ + run: true, + transformers: { + echo: { + code: async (context) => ({ + type: 'echo', + config: context.config, + push: async (event) => ({ event }), + }), + }, + }, + destinations: { + sink: { + code: { type: 'sink', config: {}, push: async () => undefined }, + before: 'echo', + }, + }, + }); + collector.observers.add( + createTelemetryObserver((state) => states.push(state), { + flowId: 'default', + level: 'standard', + }), + ); + + await elb({ name: 'page view', data: {} }); + + const inFrame = states.find( + (s) => + s.stepType === 'transformer' && + s.stepId === 'transformer.echo' && + s.phase === 'in', + ); + expect(inFrame).toBeDefined(); + expect(inFrame?.inEvent).toBeUndefined(); + }); }); diff --git a/packages/collector/src/destination.ts b/packages/collector/src/destination.ts index fceead997..c5b6b11f7 100644 --- a/packages/collector/src/destination.ts +++ b/packages/collector/src/destination.ts @@ -1451,6 +1451,7 @@ export async function destinationPush( if (processed.event.consent) { inState.consent = { ...processed.event.consent }; } + inState.inEvent = processed.event; emitStep(collector, inState); try { @@ -1481,7 +1482,10 @@ export async function destinationPush( now: pushFinished, }); outState.durationMs = pushFinished - pushStarted; - outState.outEvent = response; + outState.outEvent = processed.event; + if (isDefined(response)) { + outState.meta = { ...outState.meta, response }; + } if (processed.mappingKey) outState.mappingKey = processed.mappingKey; emitStep(collector, outState); diff --git a/packages/collector/src/push.ts b/packages/collector/src/push.ts index d027ab475..4cf308087 100644 --- a/packages/collector/src/push.ts +++ b/packages/collector/src/push.ts @@ -228,16 +228,15 @@ export function createPush( const wrapped: Collector.PushFn = async (event, options) => { const eventId = typeof event.id === 'string' ? event.id : ''; const started = Date.now(); - emitStep( - collector, - buildBaseState(collector, { - stepId: 'collector.push', - stepType: 'collector', - phase: 'in', - eventId, - now: started, - }), - ); + const inState = buildBaseState(collector, { + stepId: 'collector.push', + stepType: 'collector', + phase: 'in', + eventId, + now: started, + }); + inState.inEvent = event; + emitStep(collector, inState); try { const result = await innerPush(event, options); diff --git a/packages/collector/src/transformer.ts b/packages/collector/src/transformer.ts index e769ca759..b779036e1 100644 --- a/packages/collector/src/transformer.ts +++ b/packages/collector/src/transformer.ts @@ -491,6 +491,7 @@ export async function transformerPush( eventId, now: started, }); + inState.inEvent = event; emitStep(collector, inState); try { From 31c6858edac57c43c10ff324eccd2c7c3cb348b6 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 17 Jun 2026 14:01:47 +0200 Subject: [PATCH 4/9] CSPRNG --- .changeset/getid-secure-random.md | 9 ++++ packages/core/src/__tests__/getId.test.ts | 40 +++++++++++++++ packages/core/src/getId.ts | 49 +++++++++++++++---- .../src/__tests__/sessionStorage.test.ts | 6 +-- .../src/__tests__/stepExamples.test.ts | 20 ++++---- .../sources/session/src/lib/sessionStorage.ts | 4 +- .../sources/session/src/lib/sessionWindow.ts | 2 +- 7 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 .changeset/getid-secure-random.md diff --git a/.changeset/getid-secure-random.md b/.changeset/getid-secure-random.md new file mode 100644 index 000000000..3563229dc --- /dev/null +++ b/.changeset/getid-secure-random.md @@ -0,0 +1,9 @@ +--- +'@walkeros/core': patch +'@walkeros/web-source-session': patch +--- + +`getId` now draws from the platform's cryptographic random source +(`crypto.getRandomValues`) when available, with unbiased character sampling and +a `Math.random` fallback. Session and device ids generated by the session source +are now longer for a much wider collision margin. diff --git a/packages/core/src/__tests__/getId.test.ts b/packages/core/src/__tests__/getId.test.ts index ccbfed71b..9097fcfa5 100644 --- a/packages/core/src/__tests__/getId.test.ts +++ b/packages/core/src/__tests__/getId.test.ts @@ -1,6 +1,10 @@ import { getId } from '../getId'; describe('getId', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('uses base-36 charset by default', () => { for (let i = 0; i < 100; i++) { expect(getId(8)).toMatch(/^[a-z0-9]{8}$/); @@ -12,10 +16,46 @@ describe('getId', () => { expect(getId(12)).toHaveLength(12); }); + it('always returns exactly the requested length', () => { + for (let i = 0; i < 50; i++) { + expect(getId(16)).toHaveLength(16); + } + }); + it('restricts output to a custom charset', () => { const alpha = 'abcdefghijklmnopqrstuvwxyz'; for (let i = 0; i < 100; i++) { expect(getId(5, alpha)).toMatch(/^[a-z]{5}$/); } }); + + it('draws from crypto.getRandomValues when available', () => { + const spy = jest.spyOn(globalThis.crypto, 'getRandomValues'); + const random = jest.spyOn(Math, 'random'); + + const id = getId(16); + + expect(id).toHaveLength(16); + expect(spy).toHaveBeenCalled(); + expect(random).not.toHaveBeenCalled(); + }); + + it('falls back to Math.random when crypto.getRandomValues throws', () => { + jest.spyOn(globalThis.crypto, 'getRandomValues').mockImplementation(() => { + throw new Error('crypto unavailable'); + }); + const random = jest.spyOn(Math, 'random'); + + const id = getId(16); + + expect(id).toMatch(/^[a-z0-9]{16}$/); + expect(random).toHaveBeenCalled(); + }); + + it('generates distinct ids', () => { + const ids = new Set(); + const count = 1000; + for (let i = 0; i < count; i++) ids.add(getId(16)); + expect(ids.size).toBe(count); + }); }); diff --git a/packages/core/src/getId.ts b/packages/core/src/getId.ts index ecc18c21c..1d26ee5c0 100644 --- a/packages/core/src/getId.ts +++ b/packages/core/src/getId.ts @@ -1,22 +1,51 @@ /** * Generates a random string of a given length. * + * Draws from the platform CSPRNG (`crypto.getRandomValues`) when available, + * with rejection sampling to keep the character distribution unbiased. Falls + * back to `Math.random` only when no secure source is reachable. + * * @param length - The length of the random string. * @param charset - Optional custom charset. Defaults to base-36 (0-9a-z). * @returns The random string. */ -export function getId(length = 6, charset?: string): string { - if (charset) { - const n = charset.length; - let str = ''; - for (let i = 0; i < length; i++) { - str += charset[(Math.random() * n) | 0]; +const defaultCharset = '0123456789abcdefghijklmnopqrstuvwxyz'; + +export function getId(length = 6, charset: string = defaultCharset): string { + const n = charset.length; + if (length <= 0 || n === 0) return ''; + + let str = ''; + + // Single-byte charsets can be sampled from the CSPRNG. Reject bytes above the + // largest multiple of n that fits in a byte so every character is equally likely. + if (n <= 256) { + const limit = 256 - (256 % n); + while (str.length < length) { + const need = length - str.length; + const buffer = new Uint8Array(Math.ceil(need * 1.3) + 4); + if (!secureBytes(buffer)) break; // No secure source, drop to fallback below + for (let i = 0; i < buffer.length && str.length < length; i++) { + if (buffer[i] < limit) str += charset[buffer[i] % n]; + } } - return str; } - let str = ''; - for (let l = 36; str.length < length; ) - str += ((Math.random() * l) | 0).toString(l); + // Fallback for a missing CSPRNG or an oversized charset. + while (str.length < length) str += charset[(Math.random() * n) | 0]; + return str; } + +function secureBytes(buffer: Uint8Array): boolean { + try { + const cryptoObj: Crypto | undefined = globalThis.crypto; + if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') { + cryptoObj.getRandomValues(buffer); + return true; + } + } catch { + // Secure source unavailable, signal the caller to use the fallback. + } + return false; +} diff --git a/packages/web/sources/session/src/__tests__/sessionStorage.test.ts b/packages/web/sources/session/src/__tests__/sessionStorage.test.ts index 79bb3a053..a31f99de9 100644 --- a/packages/web/sources/session/src/__tests__/sessionStorage.test.ts +++ b/packages/web/sources/session/src/__tests__/sessionStorage.test.ts @@ -89,8 +89,8 @@ describe('SessionStorage', () => { }), ); - expect(session.device).toHaveLength(8); - expect(session.id).toHaveLength(12); + expect(session.device).toHaveLength(16); + expect(session.id).toHaveLength(16); }); test('Existing expired session', () => { @@ -128,7 +128,7 @@ describe('SessionStorage', () => { ); // Id should be different to previous session - expect(newSession.id).toHaveLength(12); + expect(newSession.id).toHaveLength(16); }); test('Storage Session Options', () => { diff --git a/packages/web/sources/session/src/__tests__/stepExamples.test.ts b/packages/web/sources/session/src/__tests__/stepExamples.test.ts index 4fb4def9a..5d1f23aea 100644 --- a/packages/web/sources/session/src/__tests__/stepExamples.test.ts +++ b/packages/web/sources/session/src/__tests__/stepExamples.test.ts @@ -74,14 +74,17 @@ describe('Step Examples', () => { const isReturning = name === 'returningVisitor'; const fakeNow = isReturning ? 1700001000000 : 1700000000000; dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(fakeNow); - coreMocked.getId.mockImplementation((len?: number) => { - if (len === 8) return 'd3v1c3-id'; - if (len === 12) { - return isReturning ? 'n3w-s3ss10n' : 's3ss10n-id'; - } - return 'id'; + coreMocked.getId.mockImplementation(() => { + return isReturning ? 'n3w-s3ss10n' : 's3ss10n-id'; }); + // Seed device ID so it reads from storage instead of being regenerated; + // getId then only ever produces the session id. + localStorage.setItem( + 'elbDeviceId', + JSON.stringify({ e: fakeNow + 3600000, v: 'd3v1c3-id' }), + ); + // Seed pre-existing session for returning visitor — old enough to expire. // storageRead wraps values as { e: expiry, v: JSON-string }. if (isReturning) { @@ -97,11 +100,6 @@ describe('Step Examples', () => { 'elbSessionId', JSON.stringify({ e: fakeNow + 3600000, v: sessionData }), ); - // Also seed device ID so we get the expected 'd3v1c3-id' without regen - localStorage.setItem( - 'elbDeviceId', - JSON.stringify({ e: fakeNow + 3600000, v: 'd3v1c3-id' }), - ); } // The ungated path defers its emit to an on('run') subscription. Capture diff --git a/packages/web/sources/session/src/lib/sessionStorage.ts b/packages/web/sources/session/src/lib/sessionStorage.ts index f9ac22d39..76ddf4eb7 100644 --- a/packages/web/sources/session/src/lib/sessionStorage.ts +++ b/packages/web/sources/session/src/lib/sessionStorage.ts @@ -40,7 +40,7 @@ export function sessionStorage( const device = tryCatch((key: string, age: number, storage: StorageType) => { let id = storageRead(key, storage, storageEnv); if (!id) { - id = getId(8); // Create a new device ID + id = getId(16); // Create a new device ID storageWrite(key, id, age * 1440, storage, undefined, storageEnv); // Write device ID to storage } return String(id); @@ -90,7 +90,7 @@ export function sessionStorage( // Default session data const defaultSession: Partial = { - id: getId(12), + id: getId(16), start: now, isNew: true, count: 1, diff --git a/packages/web/sources/session/src/lib/sessionWindow.ts b/packages/web/sources/session/src/lib/sessionWindow.ts index 64cf0edc6..b3e6c937a 100644 --- a/packages/web/sources/session/src/lib/sessionWindow.ts +++ b/packages/web/sources/session/src/lib/sessionWindow.ts @@ -77,7 +77,7 @@ export function sessionWindow( isStart, storage: false, start: Date.now(), - id: getId(12), + id: getId(16), referrer, }, marketing, From d1b41ca5577cadb09e9cba135374d2f31ef765a9 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 17 Jun 2026 16:26:22 +0200 Subject: [PATCH 5/9] trace --- .changeset/run-scoped-trace.md | 11 +++ packages/cli/openapi/spec.json | 67 ++++++++++++++++++- packages/cli/src/types/api.gen.d.ts | 30 ++++++++- .../src/__tests__/boundary-error.test.ts | 1 + .../src/__tests__/handle-createEvent.test.ts | 33 +++++++++ .../collector/src/__tests__/handle.test.ts | 14 +++- .../src/__tests__/inline-code.test.ts | 1 + .../src/__tests__/observerEmit.test.ts | 1 + .../src/__tests__/queue-bounds.test.ts | 1 + .../collector/src/__tests__/run-trace.test.ts | 29 ++++++++ .../store-cache-wrapper.observer.test.ts | 1 + .../collector/src/__tests__/store.test.ts | 1 + .../src/__tests__/transformer-branch.test.ts | 1 + .../__tests__/transformer-init-error.test.ts | 1 + .../src/__tests__/transformer.test.ts | 1 + packages/collector/src/collector.ts | 1 + packages/collector/src/handle.ts | 21 +++++- packages/core/src/__tests__/emitStep.test.ts | 1 + packages/core/src/__tests__/getSpanId.test.ts | 7 ++ .../core/src/__tests__/getTraceId.test.ts | 28 ++++++++ packages/core/src/getSpanId.ts | 8 +-- packages/core/src/getTraceId.ts | 9 +++ packages/core/src/hexId.ts | 14 ++++ packages/core/src/index.ts | 1 + packages/core/src/schemas/walkeros.ts | 5 +- packages/core/src/types/collector.ts | 4 ++ packages/core/src/types/walkeros.ts | 4 +- .../gcp/src/pubsub/examples/step.ts | 17 ++++- skills/walkeros-understanding-events/SKILL.md | 27 +++++--- website/docs/getting-started/event-model.mdx | 18 +++-- 30 files changed, 324 insertions(+), 34 deletions(-) create mode 100644 .changeset/run-scoped-trace.md create mode 100644 packages/collector/src/__tests__/run-trace.test.ts create mode 100644 packages/core/src/__tests__/getTraceId.test.ts create mode 100644 packages/core/src/getTraceId.ts create mode 100644 packages/core/src/hexId.ts diff --git a/.changeset/run-scoped-trace.md b/.changeset/run-scoped-trace.md new file mode 100644 index 000000000..b9f4357e2 --- /dev/null +++ b/.changeset/run-scoped-trace.md @@ -0,0 +1,11 @@ +--- +'@walkeros/core': patch +'@walkeros/collector': patch +--- + +The collector now stamps a run-scoped trace id (`event.source.trace`) and a +per-run sequence number (`event.source.count`) onto every event, minted fresh on +each `run`. These group all events of a page load or run and are preserved +unchanged when events are forwarded from web to server, giving a stable +correlation id across the pipeline. Adds `getTraceId`, and `getSpanId` now uses +the cryptographic random source. diff --git a/packages/cli/openapi/spec.json b/packages/cli/openapi/spec.json index 41406f999..2de891baf 100644 --- a/packages/cli/openapi/spec.json +++ b/packages/cli/openapi/spec.json @@ -2056,6 +2056,25 @@ }, "required": ["ack", "deploymentId", "action"] }, + "ObserveTicketRequest": { + "type": "object", + "properties": { + "scope": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["session"] + }, + "sessionId": { + "type": "string", + "minLength": 1 + } + }, + "required": ["kind", "sessionId"] + } + } + }, "ObserveTicketResponse": { "type": "object", "properties": { @@ -2202,6 +2221,9 @@ "type": "object", "additionalProperties": {} }, + "observedFlowName": { + "type": ["string", "null"] + }, "serverFlowName": { "type": ["string", "null"] }, @@ -2226,6 +2248,7 @@ "status", "errorMessage", "configSnapshot", + "observedFlowName", "serverFlowName", "serverEndpoint", "web", @@ -4928,9 +4951,29 @@ } }, "required": ["size", "ttlMs"] + }, + "scope": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["session"] + }, + "sessionId": { + "type": "string" + } + }, + "required": ["kind", "sessionId"] + }, + { + "type": "null" + } + ] } }, - "required": ["userId", "projectId", "replay"] + "required": ["userId", "projectId", "replay", "scope"] }, "ValidateTicketRequest": { "type": "object", @@ -9824,7 +9867,7 @@ "post": { "tags": ["Observe"], "summary": "Create SSE ticket", - "description": "Generate a one-time ticket for authenticating an SSE connection to the Observer service. Requires project membership.", + "description": "Generate a one-time ticket for authenticating an SSE connection to the Observer service. Requires project membership. An optional scope narrows the ticket to a subset of the project feed (e.g. one Observe session).", "parameters": [ { "schema": { @@ -9837,6 +9880,16 @@ "in": "path" } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObserveTicketRequest" + } + } + } + }, "responses": { "200": { "description": "Ticket generated", @@ -9858,6 +9911,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { diff --git a/packages/cli/src/types/api.gen.d.ts b/packages/cli/src/types/api.gen.d.ts index 7a03c78ef..e0ce88a18 100644 --- a/packages/cli/src/types/api.gen.d.ts +++ b/packages/cli/src/types/api.gen.d.ts @@ -4197,7 +4197,7 @@ export interface paths { put?: never; /** * Create SSE ticket - * @description Generate a one-time ticket for authenticating an SSE connection to the Observer service. Requires project membership. + * @description Generate a one-time ticket for authenticating an SSE connection to the Observer service. Requires project membership. An optional scope narrows the ticket to a subset of the project feed (e.g. one Observe session). */ post: { parameters: { @@ -4208,7 +4208,11 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'application/json': components['schemas']['ObserveTicketRequest']; + }; + }; responses: { /** @description Ticket generated */ 200: { @@ -4228,6 +4232,15 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; /** @description Not found */ 404: { headers: { @@ -8424,6 +8437,13 @@ export interface components { versionNumber?: number; bundleUrl?: string; }; + ObserveTicketRequest: { + scope?: { + /** @enum {string} */ + kind: 'session'; + sessionId: string; + }; + }; ObserveTicketResponse: { ticket: string; /** Format: uri */ @@ -8473,6 +8493,7 @@ export interface components { configSnapshot: { [key: string]: unknown; }; + observedFlowName: string | null; serverFlowName: string | null; serverEndpoint: string | null; web: components['schemas']['ObserveSessionWeb']; @@ -9452,6 +9473,11 @@ export interface components { size: number; ttlMs: number; }; + scope: { + /** @enum {string} */ + kind: 'session'; + sessionId: string; + } | null; }; ValidateTicketRequest: { ticket: string; diff --git a/packages/collector/src/__tests__/boundary-error.test.ts b/packages/collector/src/__tests__/boundary-error.test.ts index c4bb748f7..bd95d4dae 100644 --- a/packages/collector/src/__tests__/boundary-error.test.ts +++ b/packages/collector/src/__tests__/boundary-error.test.ts @@ -52,6 +52,7 @@ function createTestCollector(): Collector.Instance { on: {}, queue: [], round: 0, + count: 0, session: undefined, timing: Date.now(), user: {}, diff --git a/packages/collector/src/__tests__/handle-createEvent.test.ts b/packages/collector/src/__tests__/handle-createEvent.test.ts index eb60c5849..8a6d1683e 100644 --- a/packages/collector/src/__tests__/handle-createEvent.test.ts +++ b/packages/collector/src/__tests__/handle-createEvent.test.ts @@ -1,4 +1,5 @@ import { collector } from '../collector'; +import { startFlow } from '..'; import { createEvent } from '../handle'; describe('createEvent v4', () => { @@ -17,4 +18,36 @@ describe('createEvent v4', () => { expect(ev.source.type).toBe('browser'); expect(ev.source.url).toBe('https://x.test/'); }); + + it('initialises run-scoped trace state', async () => { + const c = await collector({}); + expect(c.count).toBe(0); + expect(c.trace).toBeUndefined(); + }); + + it('stamps source.trace from the run and increments source.count per event', async () => { + const { collector: c } = await startFlow(); + const e1 = createEvent(c, { name: 'page view' }); + const e2 = createEvent(c, { name: 'page view' }); + const e3 = createEvent(c, { name: 'page view' }); + expect(e1.source.trace).toBe(c.trace); + expect(e2.source.trace).toBe(c.trace); + expect([e1.source.count, e2.source.count, e3.source.count]).toEqual([ + 1, 2, 3, + ]); + expect(e1.id).toMatch(/^[0-9a-f]{16}$/); + }); + + it('never overwrites trace/count/id already set on a forwarded event', async () => { + const { collector: c } = await startFlow(); + const forwarded = createEvent(c, { + name: 'page view', + id: 'aaaaaaaaaaaaaaaa', + source: { type: 'express', trace: 'f'.repeat(32), count: 7 }, + }); + expect(forwarded.id).toBe('aaaaaaaaaaaaaaaa'); + expect(forwarded.source.trace).toBe('f'.repeat(32)); + expect(forwarded.source.count).toBe(7); + expect(c.count).toBe(0); + }); }); diff --git a/packages/collector/src/__tests__/handle.test.ts b/packages/collector/src/__tests__/handle.test.ts index 33886cea3..e65bf6135 100644 --- a/packages/collector/src/__tests__/handle.test.ts +++ b/packages/collector/src/__tests__/handle.test.ts @@ -367,8 +367,18 @@ describe('enrichEvent', () => { name: 'page view', id: 'fixed', }; - expect(enrichEvent(c, partial)).toEqual( - createEvent(c, prepareEvent(c, partial)), + const viaEnrich = enrichEvent(c, partial); + const viaCompose = createEvent(c, prepareEvent(c, partial)); + // Both paths produce the same event. `source.count` is a per-run sequence + // that legitimately advances on each createEvent call, so compare it + // independently and assert deep equality on everything else. + expect(viaEnrich.source.count).toBe(1); + expect(viaCompose.source.count).toBe(2); + expect({ ...viaEnrich, source: { ...viaEnrich.source, count: 0 } }).toEqual( + { + ...viaCompose, + source: { ...viaCompose.source, count: 0 }, + }, ); }); }); diff --git a/packages/collector/src/__tests__/inline-code.test.ts b/packages/collector/src/__tests__/inline-code.test.ts index 604f0d664..e5c108211 100644 --- a/packages/collector/src/__tests__/inline-code.test.ts +++ b/packages/collector/src/__tests__/inline-code.test.ts @@ -47,6 +47,7 @@ describe('Inline Code Support ($code: prefix equivalent)', () => { on: {}, queue: [], round: 0, + count: 0, session: undefined, timing: Date.now(), user: {}, diff --git a/packages/collector/src/__tests__/observerEmit.test.ts b/packages/collector/src/__tests__/observerEmit.test.ts index f6ee1e2ac..9a6580662 100644 --- a/packages/collector/src/__tests__/observerEmit.test.ts +++ b/packages/collector/src/__tests__/observerEmit.test.ts @@ -23,6 +23,7 @@ function makeCollector(observers: Set): Collector.Instance { on: {}, queue: [], round: 0, + count: 0, stateVersion: 0, cellVersion: {}, delivery: new WeakMap(), diff --git a/packages/collector/src/__tests__/queue-bounds.test.ts b/packages/collector/src/__tests__/queue-bounds.test.ts index 73e950e77..52198c2a7 100644 --- a/packages/collector/src/__tests__/queue-bounds.test.ts +++ b/packages/collector/src/__tests__/queue-bounds.test.ts @@ -46,6 +46,7 @@ describe('queue bounds', () => { consent: args.consent || {}, queue: [], round: 0, + count: 0, stateVersion: 0, cellVersion: {}, delivery: new WeakMap(), diff --git a/packages/collector/src/__tests__/run-trace.test.ts b/packages/collector/src/__tests__/run-trace.test.ts new file mode 100644 index 000000000..5d0e32674 --- /dev/null +++ b/packages/collector/src/__tests__/run-trace.test.ts @@ -0,0 +1,29 @@ +import { startFlow } from '..'; + +describe('run-scoped trace', () => { + it('mints a fresh 32-hex trace and resets count on each run', async () => { + const { collector: c } = await startFlow(); + const first = c.trace; + expect(first).toMatch(/^[0-9a-f]{32}$/); + expect(c.count).toBe(0); + + await c.command('run'); + expect(c.trace).toMatch(/^[0-9a-f]{32}$/); + expect(c.trace).not.toBe(first); + expect(c.count).toBe(0); + }); + + it('preserves inbound trace/span/count through a second collector', async () => { + const { collector: server } = await startFlow(); + const inbound = { + name: 'order complete', + id: '1111111111111111', + source: { type: 'express', trace: 'a'.repeat(32), count: 2 }, + }; + const { event } = await server.push(inbound); + expect(event?.id).toBe('1111111111111111'); + expect(event?.source.trace).toBe('a'.repeat(32)); + expect(event?.source.count).toBe(2); + expect(server.count).toBe(0); + }); +}); diff --git a/packages/collector/src/__tests__/store-cache-wrapper.observer.test.ts b/packages/collector/src/__tests__/store-cache-wrapper.observer.test.ts index 84d12c0a7..0bb647a60 100644 --- a/packages/collector/src/__tests__/store-cache-wrapper.observer.test.ts +++ b/packages/collector/src/__tests__/store-cache-wrapper.observer.test.ts @@ -25,6 +25,7 @@ function createTestCollector(): Collector.Instance { on: {}, queue: [], round: 0, + count: 0, stateVersion: 0, cellVersion: {}, delivery: new WeakMap(), diff --git a/packages/collector/src/__tests__/store.test.ts b/packages/collector/src/__tests__/store.test.ts index 3c12f2ce6..1f1da3eaa 100644 --- a/packages/collector/src/__tests__/store.test.ts +++ b/packages/collector/src/__tests__/store.test.ts @@ -36,6 +36,7 @@ function createMockCollector(): Collector.Instance { on: {}, queue: [], round: 0, + count: 0, stateVersion: 0, cellVersion: {}, delivery: new WeakMap(), diff --git a/packages/collector/src/__tests__/transformer-branch.test.ts b/packages/collector/src/__tests__/transformer-branch.test.ts index 9a7ffe22b..9fe311d22 100644 --- a/packages/collector/src/__tests__/transformer-branch.test.ts +++ b/packages/collector/src/__tests__/transformer-branch.test.ts @@ -57,6 +57,7 @@ function createMockCollector( on: {}, queue: [], round: 0, + count: 0, stateVersion: 0, cellVersion: {}, delivery: new WeakMap(), diff --git a/packages/collector/src/__tests__/transformer-init-error.test.ts b/packages/collector/src/__tests__/transformer-init-error.test.ts index 0b9dc2c8c..69a2256ad 100644 --- a/packages/collector/src/__tests__/transformer-init-error.test.ts +++ b/packages/collector/src/__tests__/transformer-init-error.test.ts @@ -32,6 +32,7 @@ function createTestCollector( on: {}, queue: [], round: 0, + count: 0, session: undefined, timing: Date.now(), user: {}, diff --git a/packages/collector/src/__tests__/transformer.test.ts b/packages/collector/src/__tests__/transformer.test.ts index a501d1b04..50107f0a3 100644 --- a/packages/collector/src/__tests__/transformer.test.ts +++ b/packages/collector/src/__tests__/transformer.test.ts @@ -36,6 +36,7 @@ describe('Transformer', () => { on: {}, queue: [], round: 0, + count: 0, session: undefined, timing: Date.now(), user: {}, diff --git a/packages/collector/src/collector.ts b/packages/collector/src/collector.ts index 578a19dc6..4051f49e1 100644 --- a/packages/collector/src/collector.ts +++ b/packages/collector/src/collector.ts @@ -49,6 +49,7 @@ export async function collector( on: {}, queue: [], round: 0, + count: 0, stateVersion: 0, cellVersion: {}, delivery: new WeakMap(), diff --git a/packages/collector/src/handle.ts b/packages/collector/src/handle.ts index e85cb2986..6b622e75a 100644 --- a/packages/collector/src/handle.ts +++ b/packages/collector/src/handle.ts @@ -12,7 +12,13 @@ import { pushToDestinations, createPushResult, } from './destination'; -import { assign, getSpanId, isFunction, isString } from '@walkeros/core'; +import { + assign, + getSpanId, + getTraceId, + isFunction, + isString, +} from '@walkeros/core'; import { isObject } from '@walkeros/core'; import { processConsent } from './consent'; import { on, onApply, redeliverStateAtRun, enterCascade } from './on'; @@ -244,6 +250,13 @@ export function createEvent( source = { type: 'collector', schema: '4' }, } = partialEvent; + // Stamp run-scoped trace and the per-run sequence, but only when absent so + // forwarded events (web -> api -> server) keep their origin identity. + const count = source.count ?? (collector.count += 1); + const trace = source.trace ?? collector.trace; + const stampedSource: WalkerOS.Source = { ...source, count }; + if (trace !== undefined) stampedSource.trace = trace; + return { name, data, @@ -259,7 +272,7 @@ export function createEvent( action, timestamp, timing, - source, + source: stampedSource, }; } @@ -297,6 +310,10 @@ export async function runCollector( // Update timing for this run collector.timing = Date.now(); + // Each run starts a new trace and resets the per-run emission counter. + collector.trace = getTraceId(); + collector.count = 0; + // Update collector state if provided if (state) { // Update consent if provided diff --git a/packages/core/src/__tests__/emitStep.test.ts b/packages/core/src/__tests__/emitStep.test.ts index d9a19d155..c64549c6f 100644 --- a/packages/core/src/__tests__/emitStep.test.ts +++ b/packages/core/src/__tests__/emitStep.test.ts @@ -19,6 +19,7 @@ function makeCollector(observers: Set): Collector.Instance { on: {}, queue: [], round: 0, + count: 0, stateVersion: 0, cellVersion: {}, delivery: new WeakMap>(), diff --git a/packages/core/src/__tests__/getSpanId.test.ts b/packages/core/src/__tests__/getSpanId.test.ts index 099e00691..35669eb25 100644 --- a/packages/core/src/__tests__/getSpanId.test.ts +++ b/packages/core/src/__tests__/getSpanId.test.ts @@ -9,4 +9,11 @@ describe('getSpanId', () => { it('produces different ids on subsequent calls', () => { expect(getSpanId()).not.toBe(getSpanId()); }); + + it('draws from crypto.getRandomValues when available', () => { + const spy = jest.spyOn(globalThis.crypto, 'getRandomValues'); + getSpanId(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); }); diff --git a/packages/core/src/__tests__/getTraceId.test.ts b/packages/core/src/__tests__/getTraceId.test.ts new file mode 100644 index 000000000..9ff9cbbac --- /dev/null +++ b/packages/core/src/__tests__/getTraceId.test.ts @@ -0,0 +1,28 @@ +import { getTraceId } from '../getTraceId'; + +describe('getTraceId', () => { + it('returns a 32-char lowercase hex string (W3C trace-id shape)', () => { + for (let i = 0; i < 50; i++) { + expect(getTraceId()).toMatch(/^[0-9a-f]{32}$/); + } + }); + + it('never returns the all-zero trace id', () => { + for (let i = 0; i < 50; i++) { + expect(getTraceId()).not.toMatch(/^0+$/); + } + }); + + it('draws from crypto.getRandomValues when available', () => { + const spy = jest.spyOn(globalThis.crypto, 'getRandomValues'); + getTraceId(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('generates distinct ids', () => { + const ids = new Set(); + for (let i = 0; i < 1000; i++) ids.add(getTraceId()); + expect(ids.size).toBe(1000); + }); +}); diff --git a/packages/core/src/getSpanId.ts b/packages/core/src/getSpanId.ts index e90eefbfa..885802561 100644 --- a/packages/core/src/getSpanId.ts +++ b/packages/core/src/getSpanId.ts @@ -1,11 +1,9 @@ +import { hexId } from './hexId'; + /** * W3C span_id: 8 random bytes encoded as 16 lowercase hex characters. * Reference: W3C Trace Context (W3C Recommendation, January 2020). */ export function getSpanId(): string { - let str = ''; - for (let i = 0; i < 16; i++) { - str += ((Math.random() * 16) | 0).toString(16); - } - return str; + return hexId(16); } diff --git a/packages/core/src/getTraceId.ts b/packages/core/src/getTraceId.ts new file mode 100644 index 000000000..bdd86311b --- /dev/null +++ b/packages/core/src/getTraceId.ts @@ -0,0 +1,9 @@ +import { hexId } from './hexId'; + +/** + * W3C trace_id: 16 random bytes encoded as 32 lowercase hex characters. + * Shared by every event of a collector run. Reference: W3C Trace Context. + */ +export function getTraceId(): string { + return hexId(32); +} diff --git a/packages/core/src/hexId.ts b/packages/core/src/hexId.ts new file mode 100644 index 000000000..b76e86136 --- /dev/null +++ b/packages/core/src/hexId.ts @@ -0,0 +1,14 @@ +import { getId } from './getId'; + +const hexCharset = '0123456789abcdef'; + +/** + * Random lowercase-hex id of `length` characters via the CSPRNG-backed getId. + * Regenerates on the all-zero value, which W3C Trace Context forbids for + * trace-id and span-id (astronomically rare, but kept spec-correct). + */ +export function hexId(length: number): string { + let id = getId(length, hexCharset); + while (/^0+$/.test(id)) id = getId(length, hexCharset); + return id; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1b5f9167e..7c555acf4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export * from './deepMerge'; export * from './eventGenerator'; export * from './getId'; export * from './getSpanId'; +export * from './getTraceId'; export * from './getMarketingParameters'; export * from './invocations'; export * from './is'; diff --git a/packages/core/src/schemas/walkeros.ts b/packages/core/src/schemas/walkeros.ts index bef196e87..ab5673f12 100644 --- a/packages/core/src/schemas/walkeros.ts +++ b/packages/core/src/schemas/walkeros.ts @@ -204,7 +204,10 @@ export const SourceSchema = PropertiesSchema.and( .nonnegative() .optional() .describe('Emission sequence per run'), - trace: z.string().optional().describe('W3C traceparent full string'), + trace: z + .string() + .optional() + .describe('Trace id shared by every event of a run (W3C trace-id shape)'), url: z.string().optional(), referrer: z.string().optional(), tool: z.string().optional(), diff --git a/packages/core/src/types/collector.ts b/packages/core/src/types/collector.ts index 0ce31577b..30ff9fabe 100644 --- a/packages/core/src/types/collector.ts +++ b/packages/core/src/types/collector.ts @@ -339,6 +339,10 @@ export interface Instance { on: On.OnConfig; queue: WalkerOS.Events; round: number; + /** Run-scoped W3C trace id, minted on each run and stamped onto events. */ + trace?: string; + /** Per-run emission sequence; reset on each run, incremented per stamped event. */ + count: number; /** * Monotonic counter bumped on every accepted reactive-state mutation * (consent, user, globals, custom). Used for per-subscriber high-water-mark diff --git a/packages/core/src/types/walkeros.ts b/packages/core/src/types/walkeros.ts index ed6aee5a3..73d618abe 100644 --- a/packages/core/src/types/walkeros.ts +++ b/packages/core/src/types/walkeros.ts @@ -85,9 +85,9 @@ export interface Source extends Properties { version?: string; /** Event-model spec version. Collector defaults to "4". */ schema?: string; - /** Emission sequence per run (was: event.count). */ + /** Emission sequence within the run. */ count?: number; - /** W3C traceparent full string; set when the emission is part of a chained trace. */ + /** Trace id shared by every event of a run (W3C trace-id shape). */ trace?: string; /** Walker-controlled standard suggestions (sources may set). */ url?: string; diff --git a/packages/server/destinations/gcp/src/pubsub/examples/step.ts b/packages/server/destinations/gcp/src/pubsub/examples/step.ts index c114ad0b2..98112d553 100644 --- a/packages/server/destinations/gcp/src/pubsub/examples/step.ts +++ b/packages/server/destinations/gcp/src/pubsub/examples/step.ts @@ -9,17 +9,30 @@ import { getEvent } from '@walkeros/core'; * ['resumePublishing', topicName, orderingKey] // only on publish failure */ +// Events reaching a server destination arrive already enriched by the upstream +// collector, so they carry the run trace and per-run count. The collector +// preserves these (stamp-if-absent), keeping the example deterministic. const orderEvent = getEvent('order complete', { timestamp: 1700001100, data: { id: 'ORD-500', total: 199.99, currency: 'EUR' }, user: { id: 'usr-789' }, - source: { type: 'express', platform: 'server' }, + source: { + type: 'express', + platform: 'server', + trace: '1a2b3c4d5e6f70819a2b3c4d5e6f7081', + count: 1, + }, }); const pageEvent = getEvent('page view', { timestamp: 1700001101, data: { title: 'Documentation', url: 'https://example.com/docs' }, - source: { type: 'express', platform: 'server' }, + source: { + type: 'express', + platform: 'server', + trace: '9f8e7d6c5b4a39281706f5e4d3c2b1a0', + count: 1, + }, }); function expectedPayload(event: unknown): Buffer { diff --git a/skills/walkeros-understanding-events/SKILL.md b/skills/walkeros-understanding-events/SKILL.md index 996474d42..0be256d12 100644 --- a/skills/walkeros-understanding-events/SKILL.md +++ b/skills/walkeros-understanding-events/SKILL.md @@ -63,6 +63,8 @@ for canonical types (Event interface, plus event helpers in `event.ts`). | `source.type` | string | Source kind (`browser`, `dataLayer`, `cookiefirst`, ...) | `"browser"` | | `source.platform` | string | Runtime platform (`web`, `server`) | `"web"` | | `source.schema` | string | Source-emitted schema/version (optional) | `"datalayer-v2"` | +| `source.trace` | string | Run-scoped W3C trace_id, shared by every event of a run | `"0123...cdef"` (32 hex) | +| `source.count` | number | Per-run emission sequence (1, 2, 3, ...) | `1` | ### data Property @@ -135,8 +137,8 @@ source: { platform: 'web', // runtime: 'web' or 'server' version: '4.0.0', // source package version schema: 'datalayer-v2', // optional schema/version emitted by the source - count: 5, // event ordinal within the source - trace: '...', // W3C trace context (optional) + trace: '0123...cdef', // run-scoped W3C trace_id, groups all events of a run + count: 1, // per-run emission sequence (1, 2, 3, ...) url: 'https://...', // page URL (web only, set by web-context transformer) referrer: '...', // page referrer (web only) tool: 'cli', // tool that produced the event (optional) @@ -147,18 +149,23 @@ source: { CMP and other non-page sources do NOT set `source.url`/`source.referrer` - that's the responsibility of a web-context transformer. +`source.trace` is the run grouping key: the collector mints a fresh run-scoped +trace_id on each run and stamps it (plus a per-run `source.count`) on every +event when absent. It is preserved unchanged when an event is forwarded from web +to server, so the whole pipeline shares one trace. + ## Migration from v3 If you have existing v3 events or configs, here is the v4 mapping: -| v3 | v4 | -| --------------------------------- | --------------------------------------------------------------- | -| `event.id` = `"--"` | `event.id` = W3C span_id (16 lowercase hex chars) | -| `event.version` | removed - see `source.version` and `source.schema` | -| `event.group` | removed - use `source.trace` for correlation | -| `event.count` | removed - see `source.count` for source-emitted ordinal | -| `event.source.id` | `event.source.url` (when it meant page URL) | -| `nested: [{ type, data }]` | `nested: [{ entity, data }]` (`Entity.entity` replaces `.type`) | +| v3 | v4 | +| --------------------------------- | ------------------------------------------------------------------ | +| `event.id` = `"--"` | `event.id` = W3C span_id (16 lowercase hex chars) | +| `event.version` | removed - see `source.version` and `source.schema` | +| `event.group` | removed - use `source.trace` (run-scoped trace_id) for correlation | +| `event.count` | removed - use `source.count` (per-run emission sequence) | +| `event.source.id` | `event.source.url` (when it meant page URL) | +| `nested: [{ type, data }]` | `nested: [{ entity, data }]` (`Entity.entity` replaces `.type`) | ## Design Principles diff --git a/website/docs/getting-started/event-model.mdx b/website/docs/getting-started/event-model.mdx index 143229b91..d246c1016 100644 --- a/website/docs/getting-started/event-model.mdx +++ b/website/docs/getting-started/event-model.mdx @@ -89,6 +89,8 @@ static, their content can be defined dynamically with different value types. type: 'browser', // Source kind (e.g. browser, dataLayer, express) platform: 'web', // Runtime platform (web or server) schema: '4', // walkerOS event schema version + trace: '0123456789abcdef0123456789abcdef', // run-scoped W3C trace_id (groups all events of a run) + count: 1, // emission sequence within the run url: 'https://github.com/elbwalker/walkerOS', // Page or request URL referrer: 'https://www.walkeros.io/', // Referrer URL }, @@ -100,12 +102,16 @@ static, their content can be defined dynamically with different value types. :::info Updating from v3 The event id is now a [W3C Trace Context](https://www.w3.org/TR/trace-context/) -`span_id` (16 lowercase hex characters) generated by the collector. The -top-level `group`, `count`, and `version` fields have been removed. Inside -`source`, `type` is the source kind (`browser`, `dataLayer`, `express`, ...) -while the new `platform` field distinguishes the runtime (`web` vs `server`). -The previous `source.id` is now `source.url` and `source.previous_id` is now -`source.referrer`. +`span_id` (16 lowercase hex characters) generated by the collector per event. +The top-level `group`, `count`, and `version` fields have been removed. The +grouping that `group` provided now lives in `source.trace`, a run-scoped W3C +`trace_id` (32 lowercase hex characters) minted on each run, shared by every +event of that run and preserved unchanged across the web to server hop. The +per-run sequence that the top-level `count` provided now lives in +`source.count`. Inside `source`, `type` is the source kind (`browser`, +`dataLayer`, `express`, ...) while the new `platform` field distinguishes the +runtime (`web` vs `server`). The previous `source.id` is now `source.url` and +`source.previous_id` is now `source.referrer`. ::: **Event names** are a combination of the entities involved (*promotion*) and the From 1023c86a7a8396c116eeee84c4dc0d959f68aba2 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 17 Jun 2026 16:48:52 +0200 Subject: [PATCH 6/9] spec --- packages/collector/src/report-error.ts | 2 +- packages/server/sources/express/src/types.ts | 6 +++--- packages/transformers/validate/src/event-format.schema.ts | 6 ++++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/collector/src/report-error.ts b/packages/collector/src/report-error.ts index bdffbf058..b31f39b7f 100644 --- a/packages/collector/src/report-error.ts +++ b/packages/collector/src/report-error.ts @@ -156,7 +156,7 @@ export function buildReportError( // lost event so it is never silent. collector.status.failed++; } - logger.error('reportError', { + logger.error('report error', { error: err instanceof Error ? err.message : String(err), event: event.name, }); diff --git a/packages/server/sources/express/src/types.ts b/packages/server/sources/express/src/types.ts index febc0ca77..e1ee8cfb2 100644 --- a/packages/server/sources/express/src/types.ts +++ b/packages/server/sources/express/src/types.ts @@ -31,9 +31,9 @@ export interface Mapping { // Ack contract: a 2xx response means the event was *accepted*, not that it was // *delivered*. With `config.async` (the default, `true`) the handler responds // first and pushes to the collector without blocking the response; rejected -// pushes are logged and destination errors are DLQ'd inside the collector. The -// GET tracking pixel always responds first regardless of the flag. Set -// `config.async: false` to make the response wait for delivery to settle. +// pushes are logged and destination errors are DLQ'd inside the collector. GET +// (the tracking pixel) and POST both honor the flag: set `config.async: false` +// to make the response wait for delivery to settle before replying. export type Push = (req: Request, res: Response) => Promise; export interface Env extends CoreSource.Env { diff --git a/packages/transformers/validate/src/event-format.schema.ts b/packages/transformers/validate/src/event-format.schema.ts index 48bb4f013..bbe1aa954 100644 --- a/packages/transformers/validate/src/event-format.schema.ts +++ b/packages/transformers/validate/src/event-format.schema.ts @@ -312,7 +312,8 @@ export const eventFormatSchema = Object.freeze({ maximum: 9007199254740991, }, trace: { - description: 'W3C traceparent full string', + description: + 'Trace id shared by every event of a run (W3C trace-id shape)', type: 'string', }, url: { @@ -632,7 +633,8 @@ export const eventFormatSchema = Object.freeze({ maximum: 9007199254740991, }, trace: { - description: 'W3C traceparent full string', + description: + 'Trace id shared by every event of a run (W3C trace-id shape)', type: 'string', }, url: { From 19c2fee9fa0b0edefac0818c1c287676da7eff1e Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 17 Jun 2026 18:47:11 +0200 Subject: [PATCH 7/9] test trace --- .../core/src/__tests__/eventGenerator.test.ts | 2 + packages/core/src/eventGenerator.ts | 11 +++++ .../core/src/__tests__/collector.test.ts | 43 +++++++------------ .../destinations/gtag/src/examples/step.ts | 13 +++++- 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/packages/core/src/__tests__/eventGenerator.test.ts b/packages/core/src/__tests__/eventGenerator.test.ts index 0e5e37604..9b686f52c 100644 --- a/packages/core/src/__tests__/eventGenerator.test.ts +++ b/packages/core/src/__tests__/eventGenerator.test.ts @@ -32,6 +32,8 @@ describe('createEvent', () => { source: { type: 'collector', schema: '4', + count: 1, + trace: expect.stringMatching(/^[0-9a-f]{32}$/), }, }; diff --git a/packages/core/src/eventGenerator.ts b/packages/core/src/eventGenerator.ts index 8a6601790..275acca4a 100644 --- a/packages/core/src/eventGenerator.ts +++ b/packages/core/src/eventGenerator.ts @@ -52,6 +52,17 @@ export function createEvent( // Merge properties const event = assign(defaultEvent, props, { merge: false }); + // Mirror collector output: generated events always carry the run-stamped + // source fields (per-run count and a fixed, deterministic trace) so an event + // built from this helper matches a pushed event without tests having to + // assert them, and two generator calls produce equal events. Any source + // fields provided via props still win. + event.source = { + count: 1, + trace: '0a1b2c3d4e5f60718293a4b5c6d7e8f9', + ...event.source, + }; + // Update conditions // Entity and action from event diff --git a/packages/server/core/src/__tests__/collector.test.ts b/packages/server/core/src/__tests__/collector.test.ts index 339b47040..7e40ee34d 100644 --- a/packages/server/core/src/__tests__/collector.test.ts +++ b/packages/server/core/src/__tests__/collector.test.ts @@ -96,32 +96,17 @@ describe('Server Collector', () => { user: { id: 'us3r1d' }, consent: { test: true }, }); - const event = { - name: 'e a', - data: {}, - context: {}, - custom: {}, - globals: { glow: 'balls' }, - user: { id: 'us3r1d' }, - nested: [], - consent: { test: true }, - id: expect.any(String), - trigger: '', - entity: 'e', - action: 'a', - timestamp: expect.any(Number), - timing: expect.any(Number), - source: { - type: 'collector', - schema: '4', - version: expect.any(String), - }, - }; - await elb('e a'); expect(mockDestinationPush).toHaveBeenCalledTimes(1); expect(mockDestinationPush).toHaveBeenCalledWith( - event, + expect.objectContaining({ + name: 'e a', + entity: 'e', + action: 'a', + globals: { glow: 'balls' }, + user: { id: 'us3r1d' }, + consent: { test: true }, + }), expect.objectContaining({ config: mockDestination.config, }), @@ -189,10 +174,12 @@ describe('Server Collector', () => { }; result = await elb(mockEvent); - expect(result.event).toHaveProperty('source', { - type: 'server', - url: 'https://example.com/', - referrer: 'https://google.com/', - }); + expect(result.event?.source).toEqual( + expect.objectContaining({ + type: 'server', + url: 'https://example.com/', + referrer: 'https://google.com/', + }), + ); }); }); diff --git a/packages/web/destinations/gtag/src/examples/step.ts b/packages/web/destinations/gtag/src/examples/step.ts index 8a2c2ec9c..0eee905ab 100644 --- a/packages/web/destinations/gtag/src/examples/step.ts +++ b/packages/web/destinations/gtag/src/examples/step.ts @@ -368,7 +368,16 @@ export const ga4WithIncludeAll: Flow.StepExample = { title: 'GA4 include all', description: 'Include flattens every event section into prefixed GA4 params, exposing data, context, user, source, and event fields.', - in: getEvent('page view', { id: 'ev-1700000106', timestamp: 1700000106 }), + in: getEvent('page view', { + id: 'ev-1700000106', + timestamp: 1700000106, + source: { + type: 'collector', + schema: '4', + count: 1, + trace: '1700000106abcdef1700000106abcdef', + }, + }), mapping: { include: ['data', 'context', 'globals', 'user', 'source', 'event'], }, @@ -396,6 +405,8 @@ export const ga4WithIncludeAll: Flow.StepExample = { // source_* params from event.source source_type: 'collector', source_schema: '4', + source_count: 1, + source_trace: '1700000106abcdef1700000106abcdef', // event_* params from event properties event_entity: 'page', event_action: 'view', From 96c791a1800595b7fd7ccc97e630de0d52f1f451 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Thu, 18 Jun 2026 08:01:49 +0200 Subject: [PATCH 8/9] creds --- .changeset/bigquery-writer-credentials.md | 9 ++ packages/core/src/eventGenerator.ts | 4 + .../gcp/src/bigquery/__tests__/index.test.ts | 84 +++++++++++++++++++ .../gcp/src/bigquery/__tests__/writer.test.ts | 70 ++++++++++++++++ .../destinations/gcp/src/bigquery/config.ts | 10 ++- .../destinations/gcp/src/bigquery/index.ts | 2 + .../gcp/src/bigquery/types/index.ts | 8 ++ .../destinations/gcp/src/bigquery/writer.ts | 23 ++++- 8 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 .changeset/bigquery-writer-credentials.md diff --git a/.changeset/bigquery-writer-credentials.md b/.changeset/bigquery-writer-credentials.md new file mode 100644 index 000000000..50a7083a2 --- /dev/null +++ b/.changeset/bigquery-writer-credentials.md @@ -0,0 +1,9 @@ +--- +'@walkeros/server-destination-gcp': patch +--- + +The BigQuery destination now applies `config.credentials` to the Storage Write +client that performs event writes, not just the query client. Event writes from +the configured service account now succeed on non-Google Cloud runtimes instead +of failing with a credentials error. Both clients resolve credentials the same +way, so a destination always authenticates as a single identity. diff --git a/packages/core/src/eventGenerator.ts b/packages/core/src/eventGenerator.ts index 275acca4a..409d61fea 100644 --- a/packages/core/src/eventGenerator.ts +++ b/packages/core/src/eventGenerator.ts @@ -6,6 +6,10 @@ import { getSpanId } from './getSpanId'; * Creates a complete event with default values. * Used for testing and debugging. * + * Models a post-collector event: `source` always carries the run-stamped + * `count` and `trace`, so a generated event matches one that has been pushed + * through the collector. Override via `props.source` if needed. + * * @param props - Properties to override the default values. * @returns A complete event. */ diff --git a/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts b/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts index f61db7643..93f324883 100644 --- a/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts +++ b/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts @@ -109,6 +109,46 @@ describe('Server Destination BigQuery', () => { }); }); + test('init forwards config.credentials to BOTH the query and the Storage Write client', async () => { + if (!destination.init) throw new Error('destination.init undefined'); + const credentials = { + client_email: 'sa@example.com', + private_key: '-----BEGIN PRIVATE KEY-----', + }; + const config = await destination.init({ + config: { settings: { projectId, datasetId, tableId }, credentials }, + collector: mockCollector, + env: testEnv, + logger: createMockLogger(), + id: 'test-bq', + reportError: () => undefined, + }); + if (!config || !config.settings) throw new Error('init returned no config'); + + // Query client (BigQuery) carries the credentials on its constructor options. + // The example mock records its ctor options on `this.options`. + const queryClient: unknown = config.settings.client; + if ( + typeof queryClient !== 'object' || + queryClient === null || + !('options' in queryClient) + ) + throw new Error('mock query client did not capture options'); + const { options } = queryClient; + if (typeof options !== 'object' || options === null) + throw new Error('mock query client options not an object'); + const queryCredentials = + 'credentials' in options ? options.credentials : undefined; + expect(queryCredentials).toEqual(credentials); + + // Storage Write client (WriterClient) carries the same credentials, so it + // does not silently fall back to ADC on a non-GCP runtime. + const ctorCall = __getMockCalls().find( + (c) => c.method === 'WriterClient.ctor', + ); + expect(ctorCall?.args[0]).toEqual({ projectId, credentials }); + }); + test('init defaults datasetId to walkerOS and tableId to events', async () => { const result = await callInit({ projectId }); @@ -823,6 +863,50 @@ describe('Server Destination BigQuery', () => { expect(methods).toContain('appendRows'); }); + test('the re-opened WriterClient still carries config.credentials', async () => { + if (!destination.init) throw new Error('destination.init undefined'); + const credentials = { + client_email: 'sa@example.com', + private_key: '-----BEGIN PRIVATE KEY-----', + }; + const config = await destination.init({ + config: { settings: { projectId, datasetId, tableId }, credentials }, + collector: mockCollector, + env: testEnv, + logger: createMockLogger(), + id: 'test-bq', + reportError: () => undefined, + }); + if (!config || !config.settings) + throw new Error('init returned no config'); + const { settings } = config; + + __getLastConnection().__emitConnectionError(new Error('stream gone')); + expect(settings.writerBroken).toBe(true); + + __resetMockCalls(); + + await destination.push( + event, + createMockContext({ + config, + rule: undefined, + data: undefined, + env: testEnv, + id: 'test-bq', + }), + ); + + expect(settings.writerBroken).toBe(false); + // The re-open constructs a fresh WriterClient; it must still receive the + // resolved credentials so the self-healed writer authenticates as the + // same identity instead of falling back to ADC. + const reopenCtor = __getMockCalls().find( + (c) => c.method === 'WriterClient.ctor', + ); + expect(reopenCtor?.args[0]).toEqual({ projectId, credentials }); + }); + test('a broken writer DLQ-routes the whole batch when re-open fails (batch path)', async () => { if (!destination.pushBatch) throw new Error('pushBatch missing'); diff --git a/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts b/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts index f9805cce3..e0943ae09 100644 --- a/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts +++ b/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts @@ -57,6 +57,76 @@ describe('openWriter', () => { }); }); + test('forwards config.credentials to the WriterClient so it does not fall back to ADC', async () => { + const logger = createMockLogger(); + const credentials = { + client_email: 'sa@example.com', + private_key: '-----BEGIN PRIVATE KEY-----', + }; + await openWriter( + { projectId: 'p', datasetId: 'd', tableId: 't', credentials }, + logger, + ); + const ctorCall = __getMockCalls().find( + (c) => c.method === 'WriterClient.ctor', + ); + expect(ctorCall?.args[0]).toEqual({ projectId: 'p', credentials }); + }); + + test('config.credentials wins over settings.bigquery.credentials, matching the query client', async () => { + const logger = createMockLogger(); + const credentials = { + client_email: 'sa@example.com', + private_key: '-----BEGIN PRIVATE KEY-----', + }; + const passthroughCredentials = { + client_email: 'other@example.com', + private_key: '-----BEGIN OTHER KEY-----', + }; + await openWriter( + { + projectId: 'p', + datasetId: 'd', + tableId: 't', + credentials, + // Same key collides: config.credentials must win so both clients + // authenticate as one identity (no split-brain auth). + bigquery: { credentials: passthroughCredentials }, + }, + logger, + ); + const ctorCall = __getMockCalls().find( + (c) => c.method === 'WriterClient.ctor', + ); + expect(ctorCall?.args[0]).toEqual({ projectId: 'p', credentials }); + }); + + test('non-colliding settings.bigquery fields are preserved alongside config.credentials', async () => { + const logger = createMockLogger(); + const credentials = { + client_email: 'sa@example.com', + private_key: '-----BEGIN PRIVATE KEY-----', + }; + await openWriter( + { + projectId: 'p', + datasetId: 'd', + tableId: 't', + credentials, + bigquery: { apiEndpoint: 'http://localhost:9050' }, + }, + logger, + ); + const ctorCall = __getMockCalls().find( + (c) => c.method === 'WriterClient.ctor', + ); + expect(ctorCall?.args[0]).toEqual({ + projectId: 'p', + apiEndpoint: 'http://localhost:9050', + credentials, + }); + }); + test('forwards timeout as the gax deadline to createStreamConnection and getWriteStream', async () => { const logger = createMockLogger(); await openWriter( diff --git a/packages/server/destinations/gcp/src/bigquery/config.ts b/packages/server/destinations/gcp/src/bigquery/config.ts index db5f5d603..57a4f8cc1 100644 --- a/packages/server/destinations/gcp/src/bigquery/config.ts +++ b/packages/server/destinations/gcp/src/bigquery/config.ts @@ -24,8 +24,12 @@ export function getConfig( // `config.credentials` (parse-if-string) merges into the client options. // `settings.bigquery` stays the raw passthrough escape hatch. - const credentials = parseCredentials(partialConfig.credentials, logger); - if (credentials !== undefined && typeof credentials !== 'string') { + const parsed = parseCredentials(partialConfig.credentials, logger); + // parseCredentials returns the object form (or undefined); a string can only + // remain on the invalid-JSON path, which logger.throw already handles. + const credentials = + parsed !== undefined && typeof parsed !== 'string' ? parsed : undefined; + if (credentials !== undefined) { options.credentials = credentials; } @@ -40,6 +44,8 @@ export function getConfig( location, datasetId, tableId, + // Stored so init() can thread the same SA to the Storage Write client. + ...(credentials !== undefined ? { credentials } : {}), }; return { ...partialConfig, settings: settingsConfig }; diff --git a/packages/server/destinations/gcp/src/bigquery/index.ts b/packages/server/destinations/gcp/src/bigquery/index.ts index 5b724a4cc..d0fa8f4b7 100644 --- a/packages/server/destinations/gcp/src/bigquery/index.ts +++ b/packages/server/destinations/gcp/src/bigquery/index.ts @@ -58,6 +58,7 @@ export const destinationBigQuery: Destination = { projectId: settings.projectId, datasetId: settings.datasetId, tableId: settings.tableId, + credentials: settings.credentials, bigquery: settings.bigquery, timeout, onConnectionError, @@ -74,6 +75,7 @@ export const destinationBigQuery: Destination = { projectId: settings.projectId, datasetId: settings.datasetId, tableId: settings.tableId, + credentials: settings.credentials, bigquery: settings.bigquery, timeout, onConnectionError, diff --git a/packages/server/destinations/gcp/src/bigquery/types/index.ts b/packages/server/destinations/gcp/src/bigquery/types/index.ts index 2f38b895c..a33d69917 100644 --- a/packages/server/destinations/gcp/src/bigquery/types/index.ts +++ b/packages/server/destinations/gcp/src/bigquery/types/index.ts @@ -21,6 +21,14 @@ export interface Settings { tableId: string; location?: string; bigquery?: BigQueryOptions; + /** + * Service-account credentials parsed from `config.credentials` at init. + * Threaded to BOTH the query client (`new BigQuery`) and the data-plane + * Storage Write client (`new WriterClient`) so event writes authenticate + * with the configured SA instead of falling back to ADC on non-GCP runtimes. + * Runtime-only (the raw `config.credentials` may be a JSON string). + */ + credentials?: ServiceAccount; // Runtime-only handles populated by init(); not user-facing. writeClient?: managedwriter.WriterClient; writer?: managedwriter.JSONWriter; diff --git a/packages/server/destinations/gcp/src/bigquery/writer.ts b/packages/server/destinations/gcp/src/bigquery/writer.ts index afbcdfbfb..8fe2868b4 100644 --- a/packages/server/destinations/gcp/src/bigquery/writer.ts +++ b/packages/server/destinations/gcp/src/bigquery/writer.ts @@ -1,4 +1,4 @@ -import type { Logger } from '@walkeros/core'; +import type { Logger, ServiceAccount } from '@walkeros/core'; import type { BigQueryOptions } from '@google-cloud/bigquery'; import { managedwriter, adapt, protos } from '@google-cloud/bigquery-storage'; @@ -26,8 +26,17 @@ export interface OpenWriterArgs { projectId: string; datasetId: string; tableId: string; - // Auth forwarded from settings.bigquery so the data-plane WriterClient - // authenticates like the control plane instead of falling back to ADC. + /** + * Service-account credentials resolved from `config.credentials`. Forwarded + * to the data-plane WriterClient so event writes authenticate with the + * configured SA instead of falling back to ADC (which has no metadata server + * to query on non-GCP runtimes, e.g. Scaleway). When both this and a + * `settings.bigquery.credentials` are set, `config.credentials` wins, matching + * the query client's resolution in getConfig, so one destination always + * authenticates as a single identity across both clients. + */ + credentials?: ServiceAccount; + // Raw passthrough auth/client options for the WriterClient (the escape hatch). bigquery?: BigQueryOptions; /** * gRPC deadline in milliseconds, derived from the standard per-step @@ -79,6 +88,7 @@ export async function openWriter( projectId, datasetId, tableId, + credentials, bigquery, timeout, onConnectionError, @@ -97,9 +107,16 @@ export async function openWriter( const callOptions: CallOptions | undefined = timeout === undefined ? undefined : { timeout }; + // The WriterClient takes google-gax ClientOptions, which extend + // GoogleAuthOptions: `projectId` + `credentials` (a JWTInput, i.e. + // { client_email, private_key, ... }), the same auth surface as + // BigQueryOptions on the query client. Spread the resolved `config.credentials` + // last so it wins over any `settings.bigquery.credentials`, mirroring the query + // client's resolution in getConfig (one identity per destination). const writeClient = new managedwriter.WriterClient({ projectId, ...bigquery, + ...(credentials !== undefined ? { credentials } : {}), }); let connectionErrorListener: RemoveListener | undefined; let connection: StreamConnection | undefined; From fc1761238fe5c23a1eb6c9e5a3dcfb55257e6e6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 13:26:38 +0000 Subject: [PATCH 9/9] chore: version packages --- .changeset/bigquery-writer-credentials.md | 9 - .changeset/cli-honest-deploy-waits.md | 10 - .changeset/cli-runner-fetch-resilience.md | 9 - .changeset/cli-runtime-log-rings.md | 8 - .changeset/cli-runtime-observe-frozen-env.md | 9 - .changeset/cli-simulate-data-injection.md | 10 - .changeset/collector-consent-init-gate.md | 9 - .changeset/collector-inevent-telemetry.md | 8 - .changeset/core-simulation-mapping-key.md | 7 - .changeset/getid-secure-random.md | 9 - .changeset/mcp-deploy-manage-contract.md | 8 - .changeset/run-scoped-trace.md | 11 - .changeset/session-ungated-run-emit.md | 9 - .changeset/source-config-async.md | 9 - .changeset/step-isolation-cli.md | 9 - .changeset/step-isolation-collector.md | 9 - .changeset/step-isolation-core.md | 9 - .changeset/step-isolation-gcp.md | 7 - .changeset/usercentrics-official-consent.md | 10 - apps/cli/CHANGELOG.md | 12 + apps/cli/package.json | 4 +- apps/demos/react/CHANGELOG.md | 19 + apps/demos/react/package.json | 16 +- apps/demos/storybook/CHANGELOG.md | 12 + apps/demos/storybook/package.json | 8 +- apps/explorer/CHANGELOG.md | 17 + apps/explorer/package.json | 14 +- apps/quickstart/CHANGELOG.md | 19 + apps/quickstart/package.json | 14 +- apps/storybook-addon/CHANGELOG.md | 17 + apps/storybook-addon/package.json | 12 +- apps/walkerjs/CHANGELOG.md | 20 + apps/walkerjs/package.json | 14 +- package-lock.json | 788 +++++++++--------- packages/cli/CHANGELOG.md | 50 ++ packages/cli/package.json | 16 +- packages/collector/CHANGELOG.md | 32 + packages/collector/package.json | 6 +- packages/config/CHANGELOG.md | 2 + packages/config/package.json | 2 +- packages/core/CHANGELOG.md | 28 + packages/core/package.json | 2 +- packages/destinations/demo/CHANGELOG.md | 11 + packages/destinations/demo/package.json | 4 +- packages/mcps/mcp/CHANGELOG.md | 22 + packages/mcps/mcp/package.json | 8 +- packages/mcps/source-browser/CHANGELOG.md | 12 + packages/mcps/source-browser/package.json | 12 +- packages/server/core/CHANGELOG.md | 11 + packages/server/core/package.json | 8 +- .../destinations/amplitude/CHANGELOG.md | 12 + .../destinations/amplitude/package.json | 8 +- packages/server/destinations/api/CHANGELOG.md | 12 + packages/server/destinations/api/package.json | 6 +- packages/server/destinations/aws/CHANGELOG.md | 12 + packages/server/destinations/aws/package.json | 6 +- .../server/destinations/bing/CHANGELOG.md | 12 + .../server/destinations/bing/package.json | 8 +- .../server/destinations/criteo/CHANGELOG.md | 12 + .../server/destinations/criteo/package.json | 8 +- .../destinations/customerio/CHANGELOG.md | 12 + .../destinations/customerio/package.json | 8 +- .../destinations/datamanager/CHANGELOG.md | 12 + .../destinations/datamanager/package.json | 8 +- .../server/destinations/file/CHANGELOG.md | 12 + .../server/destinations/file/package.json | 8 +- packages/server/destinations/gcp/CHANGELOG.md | 21 + packages/server/destinations/gcp/package.json | 6 +- .../server/destinations/hubspot/CHANGELOG.md | 12 + .../server/destinations/hubspot/package.json | 8 +- .../server/destinations/kafka/CHANGELOG.md | 12 + .../server/destinations/kafka/package.json | 8 +- .../server/destinations/klaviyo/CHANGELOG.md | 12 + .../server/destinations/klaviyo/package.json | 8 +- .../server/destinations/linkedin/CHANGELOG.md | 12 + .../server/destinations/linkedin/package.json | 8 +- .../server/destinations/meta/CHANGELOG.md | 12 + .../server/destinations/meta/package.json | 8 +- .../server/destinations/mixpanel/CHANGELOG.md | 12 + .../server/destinations/mixpanel/package.json | 8 +- .../destinations/mparticle/CHANGELOG.md | 12 + .../destinations/mparticle/package.json | 8 +- .../destinations/pinterest/CHANGELOG.md | 12 + .../destinations/pinterest/package.json | 8 +- .../server/destinations/posthog/CHANGELOG.md | 12 + .../server/destinations/posthog/package.json | 8 +- .../server/destinations/reddit/CHANGELOG.md | 12 + .../server/destinations/reddit/package.json | 8 +- .../server/destinations/redis/CHANGELOG.md | 12 + .../server/destinations/redis/package.json | 8 +- .../destinations/rudderstack/CHANGELOG.md | 12 + .../destinations/rudderstack/package.json | 8 +- .../server/destinations/segment/CHANGELOG.md | 12 + .../server/destinations/segment/package.json | 8 +- .../server/destinations/slack/CHANGELOG.md | 12 + .../server/destinations/slack/package.json | 8 +- .../server/destinations/snapchat/CHANGELOG.md | 12 + .../server/destinations/snapchat/package.json | 8 +- .../server/destinations/sqlite/CHANGELOG.md | 12 + .../server/destinations/sqlite/package.json | 8 +- .../server/destinations/tiktok/CHANGELOG.md | 12 + .../server/destinations/tiktok/package.json | 8 +- .../server/destinations/twitter/CHANGELOG.md | 12 + .../server/destinations/twitter/package.json | 8 +- packages/server/sources/aws/CHANGELOG.md | 11 + packages/server/sources/aws/package.json | 6 +- packages/server/sources/express/CHANGELOG.md | 20 + packages/server/sources/express/package.json | 6 +- packages/server/sources/fetch/CHANGELOG.md | 15 + packages/server/sources/fetch/package.json | 6 +- packages/server/sources/gcp/CHANGELOG.md | 15 + packages/server/sources/gcp/package.json | 6 +- packages/server/stores/fs/CHANGELOG.md | 11 + packages/server/stores/fs/package.json | 6 +- packages/server/stores/gcs/CHANGELOG.md | 11 + packages/server/stores/gcs/package.json | 4 +- packages/server/stores/s3/CHANGELOG.md | 11 + packages/server/stores/s3/package.json | 4 +- packages/server/stores/sheets/CHANGELOG.md | 11 + packages/server/stores/sheets/package.json | 4 +- packages/server/transformers/bot/CHANGELOG.md | 11 + packages/server/transformers/bot/package.json | 4 +- .../server/transformers/file/CHANGELOG.md | 11 + .../server/transformers/file/package.json | 6 +- .../transformers/fingerprint/CHANGELOG.md | 12 + .../transformers/fingerprint/package.json | 10 +- packages/transformers/demo/CHANGELOG.md | 11 + packages/transformers/demo/package.json | 4 +- packages/transformers/ga4/CHANGELOG.md | 11 + packages/transformers/ga4/package.json | 6 +- packages/transformers/validate/CHANGELOG.md | 11 + packages/transformers/validate/package.json | 6 +- packages/web/core/CHANGELOG.md | 11 + packages/web/core/package.json | 6 +- .../web/destinations/amplitude/CHANGELOG.md | 12 + .../web/destinations/amplitude/package.json | 8 +- packages/web/destinations/api/CHANGELOG.md | 12 + packages/web/destinations/api/package.json | 8 +- .../web/destinations/clarity/CHANGELOG.md | 12 + .../web/destinations/clarity/package.json | 8 +- packages/web/destinations/d8a/CHANGELOG.md | 12 + packages/web/destinations/d8a/package.json | 8 +- .../web/destinations/fullstory/CHANGELOG.md | 12 + .../web/destinations/fullstory/package.json | 8 +- packages/web/destinations/gtag/CHANGELOG.md | 12 + packages/web/destinations/gtag/package.json | 6 +- packages/web/destinations/heap/CHANGELOG.md | 12 + packages/web/destinations/heap/package.json | 8 +- packages/web/destinations/hotjar/CHANGELOG.md | 12 + packages/web/destinations/hotjar/package.json | 8 +- .../web/destinations/linkedin/CHANGELOG.md | 12 + .../web/destinations/linkedin/package.json | 8 +- packages/web/destinations/matomo/CHANGELOG.md | 12 + packages/web/destinations/matomo/package.json | 8 +- packages/web/destinations/meta/CHANGELOG.md | 12 + packages/web/destinations/meta/package.json | 8 +- .../web/destinations/mixpanel/CHANGELOG.md | 12 + .../web/destinations/mixpanel/package.json | 8 +- .../web/destinations/optimizely/CHANGELOG.md | 12 + .../web/destinations/optimizely/package.json | 8 +- packages/web/destinations/piano/CHANGELOG.md | 12 + packages/web/destinations/piano/package.json | 8 +- .../web/destinations/pinterest/CHANGELOG.md | 12 + .../web/destinations/pinterest/package.json | 8 +- .../web/destinations/piwikpro/CHANGELOG.md | 12 + .../web/destinations/piwikpro/package.json | 8 +- .../web/destinations/plausible/CHANGELOG.md | 12 + .../web/destinations/plausible/package.json | 8 +- .../web/destinations/posthog/CHANGELOG.md | 12 + .../web/destinations/posthog/package.json | 8 +- .../web/destinations/segment/CHANGELOG.md | 12 + .../web/destinations/segment/package.json | 8 +- .../web/destinations/snowplow/CHANGELOG.md | 12 + .../web/destinations/snowplow/package.json | 10 +- packages/web/destinations/tiktok/CHANGELOG.md | 12 + packages/web/destinations/tiktok/package.json | 8 +- packages/web/sources/browser/CHANGELOG.md | 16 + packages/web/sources/browser/package.json | 8 +- .../web/sources/cmps/cookiefirst/CHANGELOG.md | 15 + .../web/sources/cmps/cookiefirst/package.json | 6 +- .../web/sources/cmps/cookiepro/CHANGELOG.md | 15 + .../web/sources/cmps/cookiepro/package.json | 6 +- .../sources/cmps/usercentrics/CHANGELOG.md | 21 + .../sources/cmps/usercentrics/package.json | 6 +- packages/web/sources/dataLayer/CHANGELOG.md | 15 + packages/web/sources/dataLayer/package.json | 6 +- packages/web/sources/demo/CHANGELOG.md | 11 + packages/web/sources/demo/package.json | 6 +- packages/web/sources/session/CHANGELOG.md | 22 + packages/web/sources/session/package.json | 8 +- website/CHANGELOG.md | 86 ++ website/package.json | 140 ++-- 192 files changed, 2041 insertions(+), 957 deletions(-) delete mode 100644 .changeset/bigquery-writer-credentials.md delete mode 100644 .changeset/cli-honest-deploy-waits.md delete mode 100644 .changeset/cli-runner-fetch-resilience.md delete mode 100644 .changeset/cli-runtime-log-rings.md delete mode 100644 .changeset/cli-runtime-observe-frozen-env.md delete mode 100644 .changeset/cli-simulate-data-injection.md delete mode 100644 .changeset/collector-consent-init-gate.md delete mode 100644 .changeset/collector-inevent-telemetry.md delete mode 100644 .changeset/core-simulation-mapping-key.md delete mode 100644 .changeset/getid-secure-random.md delete mode 100644 .changeset/mcp-deploy-manage-contract.md delete mode 100644 .changeset/run-scoped-trace.md delete mode 100644 .changeset/session-ungated-run-emit.md delete mode 100644 .changeset/source-config-async.md delete mode 100644 .changeset/step-isolation-cli.md delete mode 100644 .changeset/step-isolation-collector.md delete mode 100644 .changeset/step-isolation-core.md delete mode 100644 .changeset/step-isolation-gcp.md delete mode 100644 .changeset/usercentrics-official-consent.md diff --git a/.changeset/bigquery-writer-credentials.md b/.changeset/bigquery-writer-credentials.md deleted file mode 100644 index 50a7083a2..000000000 --- a/.changeset/bigquery-writer-credentials.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/server-destination-gcp': patch ---- - -The BigQuery destination now applies `config.credentials` to the Storage Write -client that performs event writes, not just the query client. Event writes from -the configured service account now succeed on non-Google Cloud runtimes instead -of failing with a credentials error. Both clients resolve credentials the same -way, so a destination always authenticates as a single identity. diff --git a/.changeset/cli-honest-deploy-waits.md b/.changeset/cli-honest-deploy-waits.md deleted file mode 100644 index 7b123cad3..000000000 --- a/.changeset/cli-honest-deploy-waits.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@walkeros/cli': patch ---- - -`walkeros deploy` now waits long enough to cover a full server deploy by -default, so a slow but healthy deploy is no longer aborted early and reported as -a failure. Each run sends a fresh idempotency key, so retrying after a failure -starts a new deploy instead of replaying the previous result. Failures print a -stable, machine-readable error code (with a `Retry-After` hint on rate limits), -and `deploy create` no longer prints an empty token placeholder. diff --git a/.changeset/cli-runner-fetch-resilience.md b/.changeset/cli-runner-fetch-resilience.md deleted file mode 100644 index 1f9da5ff7..000000000 --- a/.changeset/cli-runner-fetch-resilience.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/cli': patch ---- - -The managed flow runner now retries its bundle, config, and secret fetches on -transient failures (timeouts, network errors, 5xx) with bounded, jittered -backoff capped well inside the container health window, and the secret fetch is -now bounded by a timeout. A brief outage while a flow container starts no longer -hard-fails the run. diff --git a/.changeset/cli-runtime-log-rings.md b/.changeset/cli-runtime-log-rings.md deleted file mode 100644 index a0b70b3ca..000000000 --- a/.changeset/cli-runtime-log-rings.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/cli': patch ---- - -The managed flow runner now reports its recent errors and recent log output in -its heartbeat, so deployed flows can surface runtime errors and logs in the app -without any external log tooling. Secrets are redacted before leaving the -runner. diff --git a/.changeset/cli-runtime-observe-frozen-env.md b/.changeset/cli-runtime-observe-frozen-env.md deleted file mode 100644 index 1823ac19d..000000000 --- a/.changeset/cli-runtime-observe-frozen-env.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/cli': patch ---- - -`walkeros run` reads two new environment variables. `WALKEROS_OBSERVE_LEVEL` -sets the runtime's baseline telemetry level (`off`, `standard`, or `trace`). -`WALKEROS_CONFIG_FROZEN` (`1` or `true`) serves the bundle as an immutable -snapshot: secrets are still injected at boot, but config hot-swap and heartbeat -are disabled. diff --git a/.changeset/cli-simulate-data-injection.md b/.changeset/cli-simulate-data-injection.md deleted file mode 100644 index 65779eb05..000000000 --- a/.changeset/cli-simulate-data-injection.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@walkeros/cli': patch ---- - -All four simulate functions (`simulateSource`, `simulateTransformer`, -`simulateCollector`, `simulateDestination`) accept a new `data` option to run an -existing bundle with updated configuration values, without rebundling. The new -`buildDataPayload`, `classifyStepProperties`, and `containsCodeMarkers` exports -build and inspect that payload. Destination simulation results now include -`mappingKey`, the entity-action key of the matched mapping rule. diff --git a/.changeset/collector-consent-init-gate.md b/.changeset/collector-consent-init-gate.md deleted file mode 100644 index 1a96f2064..000000000 --- a/.changeset/collector-consent-init-gate.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/collector': patch ---- - -Enforce consent gating on destination initialization. A destination that -declares a consent requirement is never initialized while that consent is -denied, including the path that flushes queued `on` (consent) signals. -Initialization is now fail-closed: it requires an affirmative consent decision -from the caller, so a destination cannot load or send under denied consent. diff --git a/.changeset/collector-inevent-telemetry.md b/.changeset/collector-inevent-telemetry.md deleted file mode 100644 index 601e6204d..000000000 --- a/.changeset/collector-inevent-telemetry.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/collector': patch ---- - -Trace-level telemetry now carries the inbound event on every pipeline hop, so -per-step observers can show what each collector, transformer, and destination -actually received. The destination's outbound frame now reports the delivered -event as its payload, and the raw delivery response moves to `meta.response`. diff --git a/.changeset/core-simulation-mapping-key.md b/.changeset/core-simulation-mapping-key.md deleted file mode 100644 index 62a796947..000000000 --- a/.changeset/core-simulation-mapping-key.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@walkeros/core': patch ---- - -Simulation step results gain an optional `mappingKey` field reporting the -entity-action key of the mapping rule a destination matched during simulation. -The field is additive and present only when a rule matched. diff --git a/.changeset/getid-secure-random.md b/.changeset/getid-secure-random.md deleted file mode 100644 index 3563229dc..000000000 --- a/.changeset/getid-secure-random.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/core': patch -'@walkeros/web-source-session': patch ---- - -`getId` now draws from the platform's cryptographic random source -(`crypto.getRandomValues`) when available, with unbiased character sampling and -a `Math.random` fallback. Session and device ids generated by the session source -are now longer for a much wider collision margin. diff --git a/.changeset/mcp-deploy-manage-contract.md b/.changeset/mcp-deploy-manage-contract.md deleted file mode 100644 index f6acce385..000000000 --- a/.changeset/mcp-deploy-manage-contract.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/mcp': patch ---- - -The `deploy_manage` tool now matches its real behavior: `deploy` honors `wait`, -`delete` removes an active deployment, and `list` accepts `cursor` and `limit` -for pagination. A failed deployment surfaces its error reason so an assistant -can report why a deploy did not succeed. diff --git a/.changeset/run-scoped-trace.md b/.changeset/run-scoped-trace.md deleted file mode 100644 index b9f4357e2..000000000 --- a/.changeset/run-scoped-trace.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@walkeros/core': patch -'@walkeros/collector': patch ---- - -The collector now stamps a run-scoped trace id (`event.source.trace`) and a -per-run sequence number (`event.source.count`) onto every event, minted fresh on -each `run`. These group all events of a page load or run and are preserved -unchanged when events are forwarded from web to server, giving a stable -correlation id across the pipeline. Adds `getTraceId`, and `getSpanId` now uses -the cryptographic random source. diff --git a/.changeset/session-ungated-run-emit.md b/.changeset/session-ungated-run-emit.md deleted file mode 100644 index b3f991ba6..000000000 --- a/.changeset/session-ungated-run-emit.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/web-source-session': patch ---- - -Fix `session start` being dropped when the collector starts with `run: false` -and no consent requirement. Without a consent rule the source emitted during -init, before the collector was allowed, so the event never reached destinations. -The emit now waits for the run lifecycle, matching the browser source's page -view timing, so it lands reliably once the collector runs. diff --git a/.changeset/source-config-async.md b/.changeset/source-config-async.md deleted file mode 100644 index f33162ed1..000000000 --- a/.changeset/source-config-async.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/core': patch -'@walkeros/server-source-express': patch ---- - -Add an optional `async` option to the source config (`Source.Config.async`) for -respond-first acknowledgement on response-producing server sources. The express -source now reads `config.async` (default `true`): a 2xx response means the event -was accepted, not yet delivered. diff --git a/.changeset/step-isolation-cli.md b/.changeset/step-isolation-cli.md deleted file mode 100644 index c40a55bc5..000000000 --- a/.changeset/step-isolation-cli.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/cli': patch ---- - -The runner registers its process-error guards before startup and degrades its -readiness check after repeated out-of-band errors, so a wedged container is -recycled instead of silently hot-looping. Heartbeats now flush immediately on a -new error and on shutdown, persist errors to disk so a failure cause survives a -restart, and report their configured interval. diff --git a/.changeset/step-isolation-collector.md b/.changeset/step-isolation-collector.md deleted file mode 100644 index 140f39c5b..000000000 --- a/.changeset/step-isolation-collector.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/collector': patch ---- - -Add a per-destination circuit breaker that skips a destination after consecutive -transport failures and probes once after a cooldown, so a persistently failing -destination stops retrying on every event. Out-of-band `reportError` calls are -routed to the dead-letter queue (when an event is in hand) or counted as -connection errors and surfaced in status. diff --git a/.changeset/step-isolation-core.md b/.changeset/step-isolation-core.md deleted file mode 100644 index cf9e8a0a3..000000000 --- a/.changeset/step-isolation-core.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/core': patch ---- - -Add an optional `reportError` callback to the step context so any source, -transformer, store, or destination can report an out-of-band error (for example -from an SDK's event emitter) into the pipeline's failure handling. Add an -optional per-destination `breaker` config to skip a destination after repeated -transport failures. diff --git a/.changeset/step-isolation-gcp.md b/.changeset/step-isolation-gcp.md deleted file mode 100644 index e26e81e80..000000000 --- a/.changeset/step-isolation-gcp.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@walkeros/server-destination-gcp': patch ---- - -Capture BigQuery Storage Write stream errors so a broken writer routes events to -the dead-letter queue instead of crashing the process, and re-open a broken -writer automatically on the next event. diff --git a/.changeset/usercentrics-official-consent.md b/.changeset/usercentrics-official-consent.md deleted file mode 100644 index 16eb88557..000000000 --- a/.changeset/usercentrics-official-consent.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@walkeros/web-source-cmp-usercentrics': patch ---- - -Use the official Usercentrics events (UC_UI_INITIALIZED, UC_UI_CMP_EVENT) and -consent getters so a returning visitor's prior choice is applied on load and -first-visit defaults stay suppressed under explicitOnly. The configurable -eventName data-layer setting is removed; the source now uses the always-emitted -official events. Fixes consent-change events being dropped on the current -Usercentrics Web CMP. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 6fed72df2..ad138af38 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,17 @@ # walkeros +## 4.2.1 + +### Patch Changes + +- Updated dependencies [b03bfce] +- Updated dependencies [ec84331] +- Updated dependencies [4809699] +- Updated dependencies [5cbcd23] +- Updated dependencies [5cbcd23] +- Updated dependencies [8afb7cc] + - @walkeros/cli@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index f061fdddb..ed48aab34 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "walkeros", - "version": "4.2.0", + "version": "4.2.1", "description": "walkerOS CLI - Bundle and deploy walkerOS components", "license": "MIT", "type": "module", @@ -18,7 +18,7 @@ "clean": "rm -rf .turbo && rm -rf dist" }, "dependencies": { - "@walkeros/cli": "4.2.0" + "@walkeros/cli": "4.2.1" }, "devDependencies": { "tsup": "^8.5.1", diff --git a/apps/demos/react/CHANGELOG.md b/apps/demos/react/CHANGELOG.md index f659d7069..6251d5153 100644 --- a/apps/demos/react/CHANGELOG.md +++ b/apps/demos/react/CHANGELOG.md @@ -1,5 +1,24 @@ # walkeros-demo-react +## 2.0.14 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + - @walkeros/web-destination-api@4.2.1 + - @walkeros/web-source-browser@4.2.1 + - @walkeros/web-core@4.2.1 + - @walkeros/web-destination-gtag@4.2.1 + ## 2.0.13 ### Patch Changes diff --git a/apps/demos/react/package.json b/apps/demos/react/package.json index 4783c3304..a594840c7 100644 --- a/apps/demos/react/package.json +++ b/apps/demos/react/package.json @@ -1,6 +1,6 @@ { "name": "walkeros-demo-react", - "version": "2.0.13", + "version": "2.0.14", "private": true, "type": "module", "scripts": { @@ -16,12 +16,12 @@ }, "dependencies": { "@remix-run/router": "^1.23.0", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-destination-api": "4.2.0", - "@walkeros/web-destination-gtag": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-destination-api": "4.2.1", + "@walkeros/web-destination-gtag": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.10.1" @@ -34,7 +34,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.2.0", + "@walkeros/config": "4.2.1", "autoprefixer": "^10.4.23", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/apps/demos/storybook/CHANGELOG.md b/apps/demos/storybook/CHANGELOG.md index 8769a4601..744fab871 100644 --- a/apps/demos/storybook/CHANGELOG.md +++ b/apps/demos/storybook/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/storybook-demo +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-source-browser@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/apps/demos/storybook/package.json b/apps/demos/storybook/package.json index 88791162c..59eb93c9f 100644 --- a/apps/demos/storybook/package.json +++ b/apps/demos/storybook/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/storybook-demo", "private": true, - "version": "4.2.0", + "version": "4.2.1", "type": "module", "scripts": { "dev": "vite", @@ -13,8 +13,8 @@ "build-storybook": "storybook build" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", "react": "^19.2.3", "react-dom": "^19.2.3" }, @@ -26,7 +26,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/storybook-addon": "4.2.0", + "@walkeros/storybook-addon": "4.2.1", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/apps/explorer/CHANGELOG.md b/apps/explorer/CHANGELOG.md index 8c1d91ad9..64e4c7418 100644 --- a/apps/explorer/CHANGELOG.md +++ b/apps/explorer/CHANGELOG.md @@ -1,5 +1,22 @@ # @walkeros/explorer +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + - @walkeros/web-source-browser@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/apps/explorer/package.json b/apps/explorer/package.json index 49923b21f..099feef2b 100644 --- a/apps/explorer/package.json +++ b/apps/explorer/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/explorer", - "version": "4.2.0", + "version": "4.2.1", "description": "Interactive React components for walkerOS documentation and exploration", "license": "MIT", "type": "module", @@ -36,10 +36,10 @@ "@rjsf/core": "^6.1.2", "@rjsf/utils": "^6.1.2", "@rjsf/validator-ajv8": "^6.1.2", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", "clsx": "^2.1.1", "monaco-editor": "^0.55.1", "prettier": "^3.7.4", @@ -65,8 +65,8 @@ "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.2.0", - "@walkeros/web-destination-gtag": "4.2.0", + "@walkeros/config": "4.2.1", + "@walkeros/web-destination-gtag": "4.2.1", "eslint": "^9.23.0", "eslint-plugin-jest": "^29.15.2", "eslint-plugin-storybook": "^10.1.11", diff --git a/apps/quickstart/CHANGELOG.md b/apps/quickstart/CHANGELOG.md index 217ac69ed..0a9d954b4 100644 --- a/apps/quickstart/CHANGELOG.md +++ b/apps/quickstart/CHANGELOG.md @@ -1,5 +1,24 @@ # @walkeros/quickstart +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + - @walkeros/web-destination-api@4.2.1 + - @walkeros/web-source-browser@4.2.1 + - @walkeros/web-core@4.2.1 + - @walkeros/web-destination-gtag@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/apps/quickstart/package.json b/apps/quickstart/package.json index aeddac2a0..57cb12d7d 100644 --- a/apps/quickstart/package.json +++ b/apps/quickstart/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/quickstart", - "version": "4.2.0", + "version": "4.2.1", "private": true, "description": "Verified code examples for walkerOS documentation", "license": "MIT", @@ -14,12 +14,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/collector": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", - "@walkeros/web-destination-gtag": "4.2.0", - "@walkeros/web-destination-api": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/collector": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", + "@walkeros/web-destination-gtag": "4.2.1", + "@walkeros/web-destination-api": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/apps/storybook-addon/CHANGELOG.md b/apps/storybook-addon/CHANGELOG.md index 1c73420e4..f7564b882 100644 --- a/apps/storybook-addon/CHANGELOG.md +++ b/apps/storybook-addon/CHANGELOG.md @@ -1,5 +1,22 @@ # @walkeros/storybook-addon +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + - @walkeros/web-source-browser@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/apps/storybook-addon/package.json b/apps/storybook-addon/package.json index 5426ff25d..1445fbb16 100644 --- a/apps/storybook-addon/package.json +++ b/apps/storybook-addon/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/storybook-addon", - "version": "4.2.0", + "version": "4.2.1", "description": "Visualize, debug, and validate walkerOS event tracking in your Storybook stories. Real-time event capture with visual DOM highlighting for data-attribute based tagging.", "keywords": [ "storybook-addons", @@ -62,10 +62,10 @@ }, "dependencies": { "@storybook/icons": "^2.0.1", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1" }, "devDependencies": { "@storybook/addon-docs": "^10.1.9", @@ -74,7 +74,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.2.0", + "@walkeros/config": "4.2.1", "auto": "^11.3.6", "boxen": "^8.0.1", "npm-run-all2": "^8.0.4", diff --git a/apps/walkerjs/CHANGELOG.md b/apps/walkerjs/CHANGELOG.md index 9af2203ba..6127f2a04 100644 --- a/apps/walkerjs/CHANGELOG.md +++ b/apps/walkerjs/CHANGELOG.md @@ -1,5 +1,25 @@ # @walkeros/walker.js +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [ec84331] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + - @walkeros/web-source-session@4.2.1 + - @walkeros/web-source-browser@4.2.1 + - @walkeros/web-source-datalayer@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/apps/walkerjs/package.json b/apps/walkerjs/package.json index 40a9dab66..2cf452341 100644 --- a/apps/walkerjs/package.json +++ b/apps/walkerjs/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/walker.js", - "version": "4.2.0", + "version": "4.2.1", "description": "Ready-to-use walkerOS bundle with browser source, collector, and dataLayer support", "license": "MIT", "main": "./dist/index.js", @@ -40,12 +40,12 @@ "preview": "npm run build && npx serve -l 3333 examples" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/collector": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", - "@walkeros/web-source-datalayer": "4.2.0", - "@walkeros/web-source-session": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/collector": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", + "@walkeros/web-source-datalayer": "4.2.1", + "@walkeros/web-source-session": "4.2.1" }, "devDependencies": { "@swc/jest": "^0.2.39", diff --git a/package-lock.json b/package-lock.json index 1448c86c4..18cb32292 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,10 +48,10 @@ }, "apps/cli": { "name": "walkeros", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/cli": "4.2.0" + "@walkeros/cli": "4.2.1" }, "bin": { "walkeros": "dist/index.js" @@ -63,15 +63,15 @@ }, "apps/demos/react": { "name": "walkeros-demo-react", - "version": "2.0.13", + "version": "2.0.14", "dependencies": { "@remix-run/router": "^1.23.0", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-destination-api": "4.2.0", - "@walkeros/web-destination-gtag": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-destination-api": "4.2.1", + "@walkeros/web-destination-gtag": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.10.1" @@ -84,7 +84,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.2.0", + "@walkeros/config": "4.2.1", "autoprefixer": "^10.4.23", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -146,10 +146,10 @@ }, "apps/demos/storybook": { "name": "@walkeros/storybook-demo", - "version": "4.2.0", + "version": "4.2.1", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", "react": "^19.2.3", "react-dom": "^19.2.3" }, @@ -162,7 +162,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/storybook-addon": "4.2.0", + "@walkeros/storybook-addon": "4.2.1", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", @@ -512,7 +512,7 @@ }, "apps/explorer": { "name": "@walkeros/explorer", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -526,10 +526,10 @@ "@rjsf/core": "^6.1.2", "@rjsf/utils": "^6.1.2", "@rjsf/validator-ajv8": "^6.1.2", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", "clsx": "^2.1.1", "monaco-editor": "^0.55.1", "prettier": "^3.7.4", @@ -551,8 +551,8 @@ "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.2.0", - "@walkeros/web-destination-gtag": "4.2.0", + "@walkeros/config": "4.2.1", + "@walkeros/web-destination-gtag": "4.2.1", "eslint": "^9.23.0", "eslint-plugin-jest": "^29.15.2", "eslint-plugin-storybook": "^10.1.11", @@ -883,27 +883,27 @@ }, "apps/quickstart": { "name": "@walkeros/quickstart", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-destination-api": "4.2.0", - "@walkeros/web-destination-gtag": "4.2.0", - "@walkeros/web-source-browser": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-destination-api": "4.2.1", + "@walkeros/web-destination-gtag": "4.2.1", + "@walkeros/web-source-browser": "4.2.1" } }, "apps/storybook-addon": { "name": "@walkeros/storybook-addon", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { "@storybook/icons": "^2.0.1", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1" }, "devDependencies": { "@storybook/addon-docs": "^10.1.9", @@ -912,7 +912,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.2.0", + "@walkeros/config": "4.2.1", "auto": "^11.3.6", "boxen": "^8.0.1", "npm-run-all2": "^8.0.4", @@ -936,15 +936,15 @@ }, "apps/walkerjs": { "name": "@walkeros/walker.js", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", - "@walkeros/web-source-datalayer": "4.2.0", - "@walkeros/web-source-session": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", + "@walkeros/web-source-datalayer": "4.2.1", + "@walkeros/web-source-session": "4.2.1" }, "devDependencies": { "@swc/jest": "^0.2.39", @@ -48981,15 +48981,15 @@ }, "packages/cli": { "name": "@walkeros/cli", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { "@vercel/nft": "^1.10.2", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", - "@walkeros/server-destination-api": "4.2.0", - "@walkeros/transformer-validate": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", + "@walkeros/server-destination-api": "4.2.1", + "@walkeros/transformer-validate": "4.2.1", "ajv": "^8.17.1", "chalk": "^5.6.2", "ci-info": "^4.4.0", @@ -49019,8 +49019,8 @@ "@types/pacote": "^11.1.8", "@types/picomatch": "4.0.3", "@types/semver": "^7.7.1", - "@walkeros/config": "4.2.0", - "@walkeros/core": "4.2.0", + "@walkeros/config": "4.2.1", + "@walkeros/core": "4.2.1", "msw": "^2.12.10", "openapi-typescript": "^7.13.0", "tsx": "^4.21.0" @@ -50213,7 +50213,7 @@ }, "packages/collector": { "name": "@walkeros/collector", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -50222,15 +50222,15 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } }, "packages/config": { "name": "@walkeros/config", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -50575,7 +50575,7 @@ }, "packages/core": { "name": "@walkeros/core", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -50589,27 +50589,27 @@ }, "packages/destinations/demo": { "name": "@walkeros/destination-demo", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } }, "packages/mcps/mcp": { "name": "@walkeros/mcp", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@walkeros/cli": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/cli": "4.2.1", + "@walkeros/core": "4.2.1" }, "bin": { "walkeros-mcp": "dist/stdio.js" }, "devDependencies": { "@types/node": "^25.9.1", - "@walkeros/config": "4.2.0" + "@walkeros/config": "4.2.1" }, "engines": { "node": ">=20.0.0" @@ -50620,12 +50620,12 @@ }, "packages/mcps/source-browser": { "name": "@walkeros/mcp-source-browser", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", "jsdom": "^29.1.1" }, "bin": { @@ -50634,9 +50634,9 @@ "devDependencies": { "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", - "@walkeros/collector": "4.2.0", - "@walkeros/config": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/config": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "engines": { "node": ">=18.0.0" @@ -51027,7 +51027,7 @@ }, "packages/server/core": { "name": "@walkeros/server-core", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51036,16 +51036,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" } }, "packages/server/destinations/amplitude": { "name": "@walkeros/server-destination-amplitude", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51055,16 +51055,16 @@ "license": "MIT", "dependencies": { "@amplitude/analytics-node": "^1.5.53", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/api": { "name": "@walkeros/server-destination-api", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51073,14 +51073,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": {} }, "packages/server/destinations/aws": { "name": "@walkeros/server-destination-aws", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51092,14 +51092,14 @@ "@aws-sdk/client-firehose": "^3.952.0", "@aws-sdk/client-sns": "^3.952.0", "@aws-sdk/client-sts": "^3.952.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": {} }, "packages/server/destinations/bing": { "name": "@walkeros/server-destination-bing", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51108,16 +51108,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/criteo": { "name": "@walkeros/server-destination-criteo", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51126,16 +51126,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/customerio": { "name": "@walkeros/server-destination-customerio", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51144,17 +51144,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "customerio-node": "^4.2.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/datamanager": { "name": "@walkeros/server-destination-datamanager", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51163,17 +51163,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "google-auth-library": "^10.5.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/file": { "name": "@walkeros/server-destination-file", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51182,16 +51182,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/gcp": { "name": "@walkeros/server-destination-gcp", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51203,14 +51203,14 @@ "@google-cloud/bigquery": "^8.1.1", "@google-cloud/bigquery-storage": "^5.1.0", "@google-cloud/pubsub": "^5.3.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": {} }, "packages/server/destinations/hubspot": { "name": "@walkeros/server-destination-hubspot", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51220,16 +51220,16 @@ "license": "MIT", "dependencies": { "@hubspot/api-client": "^13.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/kafka": { "name": "@walkeros/server-destination-kafka", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51238,17 +51238,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "kafkajs": "^2.2.4" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/klaviyo": { "name": "@walkeros/server-destination-klaviyo", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51257,17 +51257,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "klaviyo-api": "^22.0.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/linkedin": { "name": "@walkeros/server-destination-linkedin", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51276,16 +51276,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/meta": { "name": "@walkeros/server-destination-meta", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51294,16 +51294,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/mixpanel": { "name": "@walkeros/server-destination-mixpanel", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51312,17 +51312,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "mixpanel": "^0.22.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/mparticle": { "name": "@walkeros/server-destination-mparticle", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51331,16 +51331,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/pinterest": { "name": "@walkeros/server-destination-pinterest", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51349,16 +51349,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/posthog": { "name": "@walkeros/server-destination-posthog", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51367,17 +51367,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "posthog-node": "^5.0.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/reddit": { "name": "@walkeros/server-destination-reddit", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51386,16 +51386,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/redis": { "name": "@walkeros/server-destination-redis", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51404,17 +51404,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "ioredis": "^5.10.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/rudderstack": { "name": "@walkeros/server-destination-rudderstack", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51424,16 +51424,16 @@ "license": "MIT", "dependencies": { "@rudderstack/rudder-sdk-node": "^3.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/segment": { "name": "@walkeros/server-destination-segment", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51443,16 +51443,16 @@ "license": "MIT", "dependencies": { "@segment/analytics-node": "^3.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/slack": { "name": "@walkeros/server-destination-slack", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51462,16 +51462,16 @@ "license": "MIT", "dependencies": { "@slack/web-api": "^7.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/snapchat": { "name": "@walkeros/server-destination-snapchat", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51480,16 +51480,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/sqlite": { "name": "@walkeros/server-destination-sqlite", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51498,13 +51498,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { "@libsql/client": "^0.17.0", "@types/better-sqlite3": "^7.6.13", - "@walkeros/collector": "4.2.0", + "@walkeros/collector": "4.2.1", "better-sqlite3": "^12.0.0" }, "peerDependencies": { @@ -51522,7 +51522,7 @@ }, "packages/server/destinations/tiktok": { "name": "@walkeros/server-destination-tiktok", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51531,16 +51531,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/destinations/twitter": { "name": "@walkeros/server-destination-twitter", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51549,17 +51549,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "oauth-1.0a": "^2.2.6" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/server/sources/aws": { "name": "@walkeros/server-source-aws", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51570,11 +51570,11 @@ "dependencies": { "@aws-sdk/client-sns": "^3.952.0", "@aws-sdk/client-sqs": "^3.952.0", - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { "@types/aws-lambda": "^8.10.159", - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "peerDependencies": { "@types/aws-lambda": "^8.10.0" @@ -51582,7 +51582,7 @@ }, "packages/server/sources/express": { "name": "@walkeros/server-source-express", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51591,8 +51591,8 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", "cors": "^2.8.5", "express": "^5.2.1" }, @@ -51904,17 +51904,17 @@ }, "packages/server/sources/fetch": { "name": "@walkeros/server-source-fetch", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "devDependencies": {} }, "packages/server/sources/gcp": { "name": "@walkeros/server-source-gcp", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51924,8 +51924,8 @@ "license": "MIT", "dependencies": { "@google-cloud/pubsub": "^5.3.0", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "devDependencies": {}, "peerDependencies": { @@ -51934,7 +51934,7 @@ }, "packages/server/stores/fs": { "name": "@walkeros/server-store-fs", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51943,15 +51943,15 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } }, "packages/server/stores/gcs": { "name": "@walkeros/server-store-gcs", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51960,13 +51960,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": {} }, "packages/server/stores/s3": { "name": "@walkeros/server-store-s3", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51975,14 +51975,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", + "@walkeros/core": "4.2.1", "s3mini": "^0.9.1" }, "devDependencies": {} }, "packages/server/stores/sheets": { "name": "@walkeros/server-store-sheets", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -51991,13 +51991,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": {} }, "packages/server/transformers/bot": { "name": "@walkeros/server-transformer-bot", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52006,13 +52006,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", + "@walkeros/core": "4.2.1", "isbot": "^5.1.39" } }, "packages/server/transformers/file": { "name": "@walkeros/server-transformer-file", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52021,15 +52021,15 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } }, "packages/server/transformers/fingerprint": { "name": "@walkeros/server-transformer-fingerprint", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52038,12 +52038,12 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" } }, "packages/server/transformers/validate": { @@ -52064,38 +52064,38 @@ }, "packages/transformers/demo": { "name": "@walkeros/transformer-demo", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } }, "packages/transformers/ga4": { "name": "@walkeros/transformer-ga4", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } }, "packages/transformers/validate": { "name": "@walkeros/transformer-validate", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.1.1", - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } }, "packages/web/core": { "name": "@walkeros/web-core", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52104,15 +52104,15 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } }, "packages/web/destinations/amplitude": { "name": "@walkeros/web-destination-amplitude", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52122,16 +52122,16 @@ "license": "MIT", "dependencies": { "@amplitude/unified": "^1.0.16", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/api": { "name": "@walkeros/web-destination-api", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52140,16 +52140,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/clarity": { "name": "@walkeros/web-destination-clarity", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52159,16 +52159,16 @@ "license": "MIT", "dependencies": { "@microsoft/clarity": "^1.0.2", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/d8a": { "name": "@walkeros/web-destination-d8a", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52178,16 +52178,16 @@ "license": "MIT", "dependencies": { "@d8a-tech/wt": "^1.2.1", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/fullstory": { "name": "@walkeros/web-destination-fullstory", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52197,16 +52197,16 @@ "license": "MIT", "dependencies": { "@fullstory/browser": "^2.0.8", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/gtag": { "name": "@walkeros/web-destination-gtag", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52215,13 +52215,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" } }, "packages/web/destinations/heap": { "name": "@walkeros/web-destination-heap", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52230,16 +52230,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/hotjar": { "name": "@walkeros/web-destination-hotjar", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52249,16 +52249,16 @@ "license": "MIT", "dependencies": { "@hotjar/browser": "^1.0.9", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/linkedin": { "name": "@walkeros/web-destination-linkedin", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52267,16 +52267,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/matomo": { "name": "@walkeros/web-destination-matomo", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52285,16 +52285,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/meta": { "name": "@walkeros/web-destination-meta", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52303,17 +52303,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { "@types/facebook-pixel": "^0.0.31", - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/mixpanel": { "name": "@walkeros/web-destination-mixpanel", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52322,18 +52322,18 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", "mixpanel-browser": "^2.78.0" }, "devDependencies": { "@types/mixpanel-browser": "^2.50.0", - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/optimizely": { "name": "@walkeros/web-destination-optimizely", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52343,16 +52343,16 @@ "license": "MIT", "dependencies": { "@optimizely/optimizely-sdk": "^6.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/piano": { "name": "@walkeros/web-destination-piano", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52361,16 +52361,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/pinterest": { "name": "@walkeros/web-destination-pinterest", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52379,16 +52379,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/piwikpro": { "name": "@walkeros/web-destination-piwikpro", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52397,16 +52397,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/plausible": { "name": "@walkeros/web-destination-plausible", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52415,16 +52415,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/posthog": { "name": "@walkeros/web-destination-posthog", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52433,17 +52433,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1", "posthog-js": "^1.367.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/segment": { "name": "@walkeros/web-destination-segment", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52453,16 +52453,16 @@ "license": "MIT", "dependencies": { "@segment/analytics-next": "^1.82.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/destinations/snowplow": { "name": "@walkeros/web-destination-snowplow", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52471,19 +52471,19 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { "@snowplow/browser-plugin-snowplow-ecommerce": "^4.6.8", "@snowplow/browser-tracker-core": "^4.6.8", - "@walkeros/collector": "4.2.0", - "@walkeros/config": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/config": "4.2.1" } }, "packages/web/destinations/tiktok": { "name": "@walkeros/web-destination-tiktok", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52492,16 +52492,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/sources/browser": { "name": "@walkeros/web-source-browser", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52510,14 +52510,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" } }, "packages/web/sources/cmps/cookiefirst": { "name": "@walkeros/web-source-cmp-cookiefirst", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52526,14 +52526,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "devDependencies": {} }, "packages/web/sources/cmps/cookiepro": { "name": "@walkeros/web-source-cmp-cookiepro", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52542,14 +52542,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "devDependencies": {} }, "packages/web/sources/cmps/usercentrics": { "name": "@walkeros/web-source-cmp-usercentrics", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52558,13 +52558,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" } }, "packages/web/sources/dataLayer": { "name": "@walkeros/web-source-datalayer", - "version": "4.2.0", + "version": "4.2.1", "funding": [ { "type": "GitHub Sponsors", @@ -52573,8 +52573,8 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "devDependencies": { "@types/gtag.js": "^0.0.20" @@ -52582,105 +52582,105 @@ }, "packages/web/sources/demo": { "name": "@walkeros/source-demo", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "packages/web/sources/session": { "name": "@walkeros/web-source-session", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" } }, "website": { "name": "@walkeros/website", - "version": "4.2.0", + "version": "4.2.1", "dependencies": { "@docusaurus/core": "^3.9.2", "@docusaurus/preset-classic": "^3.9.2", "@docusaurus/theme-live-codeblock": "^3.9.2", "@docusaurus/theme-mermaid": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", - "@walkeros/collector": "^4.2.0", - "@walkeros/core": "^4.2.0", - "@walkeros/explorer": "^4.2.0", - "@walkeros/server-destination-amplitude": "^4.2.0", - "@walkeros/server-destination-api": "^4.2.0", - "@walkeros/server-destination-aws": "^4.2.0", - "@walkeros/server-destination-bing": "^4.2.0", - "@walkeros/server-destination-criteo": "^4.2.0", - "@walkeros/server-destination-customerio": "^4.2.0", - "@walkeros/server-destination-datamanager": "^4.2.0", - "@walkeros/server-destination-file": "^4.2.0", - "@walkeros/server-destination-gcp": "^4.2.0", - "@walkeros/server-destination-hubspot": "^4.2.0", - "@walkeros/server-destination-kafka": "^4.2.0", - "@walkeros/server-destination-klaviyo": "^4.2.0", - "@walkeros/server-destination-linkedin": "^4.2.0", - "@walkeros/server-destination-meta": "^4.2.0", - "@walkeros/server-destination-mixpanel": "^4.2.0", - "@walkeros/server-destination-mparticle": "^4.2.0", - "@walkeros/server-destination-pinterest": "^4.2.0", - "@walkeros/server-destination-posthog": "^4.2.0", - "@walkeros/server-destination-reddit": "^4.2.0", - "@walkeros/server-destination-redis": "^4.2.0", - "@walkeros/server-destination-rudderstack": "^4.2.0", - "@walkeros/server-destination-segment": "^4.2.0", - "@walkeros/server-destination-slack": "^4.2.0", - "@walkeros/server-destination-snapchat": "^4.2.0", - "@walkeros/server-destination-sqlite": "^4.2.0", - "@walkeros/server-destination-tiktok": "^4.2.0", - "@walkeros/server-destination-twitter": "^4.2.0", - "@walkeros/server-source-aws": "^4.2.0", - "@walkeros/server-source-express": "^4.2.0", - "@walkeros/server-source-fetch": "^4.2.0", - "@walkeros/server-source-gcp": "^4.2.0", - "@walkeros/server-store-fs": "^4.2.0", - "@walkeros/server-store-gcs": "^4.2.0", - "@walkeros/server-store-s3": "^4.2.0", - "@walkeros/server-store-sheets": "^4.2.0", - "@walkeros/server-transformer-bot": "^4.2.0", - "@walkeros/server-transformer-file": "^4.2.0", - "@walkeros/server-transformer-fingerprint": "^4.2.0", - "@walkeros/transformer-ga4": "^4.2.0", - "@walkeros/walker.js": "^4.2.0", - "@walkeros/web-destination-amplitude": "^4.2.0", - "@walkeros/web-destination-api": "^4.2.0", - "@walkeros/web-destination-clarity": "^4.2.0", - "@walkeros/web-destination-d8a": "^4.2.0", - "@walkeros/web-destination-fullstory": "^4.2.0", - "@walkeros/web-destination-gtag": "^4.2.0", - "@walkeros/web-destination-heap": "^4.2.0", - "@walkeros/web-destination-hotjar": "^4.2.0", - "@walkeros/web-destination-linkedin": "^4.2.0", - "@walkeros/web-destination-matomo": "^4.2.0", - "@walkeros/web-destination-meta": "^4.2.0", - "@walkeros/web-destination-mixpanel": "^4.2.0", - "@walkeros/web-destination-optimizely": "^4.2.0", - "@walkeros/web-destination-pinterest": "^4.2.0", - "@walkeros/web-destination-piwikpro": "^4.2.0", - "@walkeros/web-destination-plausible": "^4.2.0", - "@walkeros/web-destination-posthog": "^4.2.0", - "@walkeros/web-destination-segment": "^4.2.0", - "@walkeros/web-destination-snowplow": "^4.2.0", - "@walkeros/web-destination-tiktok": "^4.2.0", - "@walkeros/web-source-browser": "^4.2.0", - "@walkeros/web-source-cmp-cookiefirst": "^4.2.0", - "@walkeros/web-source-cmp-cookiepro": "^4.2.0", - "@walkeros/web-source-cmp-usercentrics": "^4.2.0", - "@walkeros/web-source-datalayer": "^4.2.0", - "@walkeros/web-source-session": "^4.2.0", + "@walkeros/collector": "^4.2.1", + "@walkeros/core": "^4.2.1", + "@walkeros/explorer": "^4.2.1", + "@walkeros/server-destination-amplitude": "^4.2.1", + "@walkeros/server-destination-api": "^4.2.1", + "@walkeros/server-destination-aws": "^4.2.1", + "@walkeros/server-destination-bing": "^4.2.1", + "@walkeros/server-destination-criteo": "^4.2.1", + "@walkeros/server-destination-customerio": "^4.2.1", + "@walkeros/server-destination-datamanager": "^4.2.1", + "@walkeros/server-destination-file": "^4.2.1", + "@walkeros/server-destination-gcp": "^4.2.1", + "@walkeros/server-destination-hubspot": "^4.2.1", + "@walkeros/server-destination-kafka": "^4.2.1", + "@walkeros/server-destination-klaviyo": "^4.2.1", + "@walkeros/server-destination-linkedin": "^4.2.1", + "@walkeros/server-destination-meta": "^4.2.1", + "@walkeros/server-destination-mixpanel": "^4.2.1", + "@walkeros/server-destination-mparticle": "^4.2.1", + "@walkeros/server-destination-pinterest": "^4.2.1", + "@walkeros/server-destination-posthog": "^4.2.1", + "@walkeros/server-destination-reddit": "^4.2.1", + "@walkeros/server-destination-redis": "^4.2.1", + "@walkeros/server-destination-rudderstack": "^4.2.1", + "@walkeros/server-destination-segment": "^4.2.1", + "@walkeros/server-destination-slack": "^4.2.1", + "@walkeros/server-destination-snapchat": "^4.2.1", + "@walkeros/server-destination-sqlite": "^4.2.1", + "@walkeros/server-destination-tiktok": "^4.2.1", + "@walkeros/server-destination-twitter": "^4.2.1", + "@walkeros/server-source-aws": "^4.2.1", + "@walkeros/server-source-express": "^4.2.1", + "@walkeros/server-source-fetch": "^4.2.1", + "@walkeros/server-source-gcp": "^4.2.1", + "@walkeros/server-store-fs": "^4.2.1", + "@walkeros/server-store-gcs": "^4.2.1", + "@walkeros/server-store-s3": "^4.2.1", + "@walkeros/server-store-sheets": "^4.2.1", + "@walkeros/server-transformer-bot": "^4.2.1", + "@walkeros/server-transformer-file": "^4.2.1", + "@walkeros/server-transformer-fingerprint": "^4.2.1", + "@walkeros/transformer-ga4": "^4.2.1", + "@walkeros/walker.js": "^4.2.1", + "@walkeros/web-destination-amplitude": "^4.2.1", + "@walkeros/web-destination-api": "^4.2.1", + "@walkeros/web-destination-clarity": "^4.2.1", + "@walkeros/web-destination-d8a": "^4.2.1", + "@walkeros/web-destination-fullstory": "^4.2.1", + "@walkeros/web-destination-gtag": "^4.2.1", + "@walkeros/web-destination-heap": "^4.2.1", + "@walkeros/web-destination-hotjar": "^4.2.1", + "@walkeros/web-destination-linkedin": "^4.2.1", + "@walkeros/web-destination-matomo": "^4.2.1", + "@walkeros/web-destination-meta": "^4.2.1", + "@walkeros/web-destination-mixpanel": "^4.2.1", + "@walkeros/web-destination-optimizely": "^4.2.1", + "@walkeros/web-destination-pinterest": "^4.2.1", + "@walkeros/web-destination-piwikpro": "^4.2.1", + "@walkeros/web-destination-plausible": "^4.2.1", + "@walkeros/web-destination-posthog": "^4.2.1", + "@walkeros/web-destination-segment": "^4.2.1", + "@walkeros/web-destination-snowplow": "^4.2.1", + "@walkeros/web-destination-tiktok": "^4.2.1", + "@walkeros/web-source-browser": "^4.2.1", + "@walkeros/web-source-cmp-cookiefirst": "^4.2.1", + "@walkeros/web-source-cmp-cookiepro": "^4.2.1", + "@walkeros/web-source-cmp-usercentrics": "^4.2.1", + "@walkeros/web-source-datalayer": "^4.2.1", + "@walkeros/web-source-session": "^4.2.1", "css-loader": "^7.1.2", "prism-react-renderer": "^2.4.1", "react": "^19.2.4", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 460f13b94..7ba6f9ff9 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,55 @@ # @walkeros/cli +## 4.2.1 + +### Patch Changes + +- b03bfce: `walkeros deploy` now waits long enough to cover a full server deploy + by default, so a slow but healthy deploy is no longer aborted early and + reported as a failure. Each run sends a fresh idempotency key, so retrying + after a failure starts a new deploy instead of replaying the previous result. + Failures print a stable, machine-readable error code (with a `Retry-After` + hint on rate limits), and `deploy create` no longer prints an empty token + placeholder. +- ec84331: The managed flow runner now retries its bundle, config, and secret + fetches on transient failures (timeouts, network errors, 5xx) with bounded, + jittered backoff capped well inside the container health window, and the + secret fetch is now bounded by a timeout. A brief outage while a flow + container starts no longer hard-fails the run. +- 4809699: The managed flow runner now reports its recent errors and recent log + output in its heartbeat, so deployed flows can surface runtime errors and logs + in the app without any external log tooling. Secrets are redacted before + leaving the runner. +- 5cbcd23: `walkeros run` reads two new environment variables. + `WALKEROS_OBSERVE_LEVEL` sets the runtime's baseline telemetry level (`off`, + `standard`, or `trace`). `WALKEROS_CONFIG_FROZEN` (`1` or `true`) serves the + bundle as an immutable snapshot: secrets are still injected at boot, but + config hot-swap and heartbeat are disabled. +- 5cbcd23: All four simulate functions (`simulateSource`, `simulateTransformer`, + `simulateCollector`, `simulateDestination`) accept a new `data` option to run + an existing bundle with updated configuration values, without rebundling. The + new `buildDataPayload`, `classifyStepProperties`, and `containsCodeMarkers` + exports build and inspect that payload. Destination simulation results now + include `mappingKey`, the entity-action key of the matched mapping rule. +- 8afb7cc: The runner registers its process-error guards before startup and + degrades its readiness check after repeated out-of-band errors, so a wedged + container is recycled instead of silently hot-looping. Heartbeats now flush + immediately on a new error and on shutdown, persist errors to disk so a + failure cause survives a restart, and report their configured interval. +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + - @walkeros/server-destination-api@4.2.1 + - @walkeros/transformer-validate@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 2622d52fa..ba59bc07c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/cli", - "version": "4.2.0", + "version": "4.2.1", "description": "walkerOS CLI - Bundle and deploy walkerOS components", "license": "MIT", "type": "module", @@ -52,11 +52,11 @@ }, "dependencies": { "@vercel/nft": "^1.10.2", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", - "@walkeros/server-destination-api": "4.2.0", - "@walkeros/transformer-validate": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", + "@walkeros/server-destination-api": "4.2.1", + "@walkeros/transformer-validate": "4.2.1", "ajv": "^8.17.1", "chalk": "^5.6.2", "ci-info": "^4.4.0", @@ -83,8 +83,8 @@ "@types/pacote": "^11.1.8", "@types/picomatch": "4.0.3", "@types/semver": "^7.7.1", - "@walkeros/config": "4.2.0", - "@walkeros/core": "4.2.0", + "@walkeros/config": "4.2.1", + "@walkeros/core": "4.2.1", "msw": "^2.12.10", "openapi-typescript": "^7.13.0", "tsx": "^4.21.0" diff --git a/packages/collector/CHANGELOG.md b/packages/collector/CHANGELOG.md index ef2b2d7de..44a050a08 100644 --- a/packages/collector/CHANGELOG.md +++ b/packages/collector/CHANGELOG.md @@ -1,5 +1,37 @@ # @walkeros/collector +## 4.2.1 + +### Patch Changes + +- bd9188d: Enforce consent gating on destination initialization. A destination + that declares a consent requirement is never initialized while that consent is + denied, including the path that flushes queued `on` (consent) signals. + Initialization is now fail-closed: it requires an affirmative consent decision + from the caller, so a destination cannot load or send under denied consent. +- d8aebd1: Trace-level telemetry now carries the inbound event on every pipeline + hop, so per-step observers can show what each collector, transformer, and + destination actually received. The destination's outbound frame now reports + the delivered event as its payload, and the raw delivery response moves to + `meta.response`. +- d1b41ca: The collector now stamps a run-scoped trace id (`event.source.trace`) + and a per-run sequence number (`event.source.count`) onto every event, minted + fresh on each `run`. These group all events of a page load or run and are + preserved unchanged when events are forwarded from web to server, giving a + stable correlation id across the pipeline. Adds `getTraceId`, and `getSpanId` + now uses the cryptographic random source. +- 8afb7cc: Add a per-destination circuit breaker that skips a destination after + consecutive transport failures and probes once after a cooldown, so a + persistently failing destination stops retrying on every event. Out-of-band + `reportError` calls are routed to the dead-letter queue (when an event is in + hand) or counted as connection errors and surfaced in status. +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/collector/package.json b/packages/collector/package.json index 1cfbc954b..f48230f2a 100644 --- a/packages/collector/package.json +++ b/packages/collector/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/collector", "description": "Unified platform-agnostic collector for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -32,7 +32,7 @@ "update": "npx npm-check-updates -u && npm update" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", @@ -61,6 +61,6 @@ } ], "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } } diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index a9b969085..7b8870630 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,5 +1,7 @@ # @walkeros/config +## 4.2.1 + ## 4.2.0 ## 4.1.2 diff --git a/packages/config/package.json b/packages/config/package.json index d8054a7b6..406d22700 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/config", - "version": "4.2.0", + "version": "4.2.1", "type": "module", "description": "Shared development configuration for walkerOS packages (TypeScript, ESLint, Jest, tsup)", "license": "MIT", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index bc42a1668..13ecdcbe2 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,33 @@ # @walkeros/core +## 4.2.1 + +### Patch Changes + +- 5cbcd23: Simulation step results gain an optional `mappingKey` field reporting + the entity-action key of the mapping rule a destination matched during + simulation. The field is additive and present only when a rule matched. +- 31c6858: `getId` now draws from the platform's cryptographic random source + (`crypto.getRandomValues`) when available, with unbiased character sampling + and a `Math.random` fallback. Session and device ids generated by the session + source are now longer for a much wider collision margin. +- d1b41ca: The collector now stamps a run-scoped trace id (`event.source.trace`) + and a per-run sequence number (`event.source.count`) onto every event, minted + fresh on each `run`. These group all events of a page load or run and are + preserved unchanged when events are forwarded from web to server, giving a + stable correlation id across the pipeline. Adds `getTraceId`, and `getSpanId` + now uses the cryptographic random source. +- 0a8a08b: Add an optional `async` option to the source config + (`Source.Config.async`) for respond-first acknowledgement on + response-producing server sources. The express source now reads `config.async` + (default `true`): a 2xx response means the event was accepted, not yet + delivered. +- 8afb7cc: Add an optional `reportError` callback to the step context so any + source, transformer, store, or destination can report an out-of-band error + (for example from an SDK's event emitter) into the pipeline's failure + handling. Add an optional per-destination `breaker` config to skip a + destination after repeated transport failures. + ## 4.2.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index c0ca0db79..538d60330 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/core", "description": "Core types and platform-agnostic utilities for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/destinations/demo/CHANGELOG.md b/packages/destinations/demo/CHANGELOG.md index 7e3cbdc79..b3157d983 100644 --- a/packages/destinations/demo/CHANGELOG.md +++ b/packages/destinations/demo/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/destination-demo +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/destinations/demo/package.json b/packages/destinations/demo/package.json index ab4d498cc..58bd197ed 100644 --- a/packages/destinations/demo/package.json +++ b/packages/destinations/demo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/destination-demo", "description": "Demo destination for walkerOS - logs events to console", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -40,7 +40,7 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/mcps/mcp/CHANGELOG.md b/packages/mcps/mcp/CHANGELOG.md index 494cd3db4..c1b93b01b 100644 --- a/packages/mcps/mcp/CHANGELOG.md +++ b/packages/mcps/mcp/CHANGELOG.md @@ -1,5 +1,27 @@ # @walkeros/mcp +## 4.2.1 + +### Patch Changes + +- b03bfce: The `deploy_manage` tool now matches its real behavior: `deploy` + honors `wait`, `delete` removes an active deployment, and `list` accepts + `cursor` and `limit` for pagination. A failed deployment surfaces its error + reason so an assistant can report why a deploy did not succeed. +- Updated dependencies [b03bfce] +- Updated dependencies [ec84331] +- Updated dependencies [4809699] +- Updated dependencies [5cbcd23] +- Updated dependencies [5cbcd23] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/cli@4.2.1 + - @walkeros/core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/mcps/mcp/package.json b/packages/mcps/mcp/package.json index af0c8408d..3c7438eff 100644 --- a/packages/mcps/mcp/package.json +++ b/packages/mcps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/mcp", - "version": "4.2.0", + "version": "4.2.1", "description": "MCP server for walkerOS flow development - discover packages, scaffold configs, validate, bundle, simulate, and test event pipelines", "license": "MIT", "type": "module", @@ -35,15 +35,15 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@walkeros/cli": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/cli": "4.2.1", + "@walkeros/core": "4.2.1" }, "peerDependencies": { "zod": "^4.0" }, "devDependencies": { "@types/node": "^25.9.1", - "@walkeros/config": "4.2.0" + "@walkeros/config": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/mcps/source-browser/CHANGELOG.md b/packages/mcps/source-browser/CHANGELOG.md index dcc984dd6..d173739d4 100644 --- a/packages/mcps/source-browser/CHANGELOG.md +++ b/packages/mcps/source-browser/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/mcp-source-browser +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-source-browser@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/mcps/source-browser/package.json b/packages/mcps/source-browser/package.json index c1388beb0..6d2a13ce2 100644 --- a/packages/mcps/source-browser/package.json +++ b/packages/mcps/source-browser/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/mcp-source-browser", - "version": "4.2.0", + "version": "4.2.1", "description": "MCP server for walkerOS data-elb HTML tagging — generate, parse, and validate tracking attributes with real DOM parsing", "license": "MIT", "type": "module", @@ -31,16 +31,16 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-source-browser": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/web-source-browser": "4.2.1", "jsdom": "^29.1.1" }, "devDependencies": { "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", - "@walkeros/config": "4.2.0", - "@walkeros/collector": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/config": "4.2.1", + "@walkeros/collector": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "peerDependencies": { "zod": "^4.0" diff --git a/packages/server/core/CHANGELOG.md b/packages/server/core/CHANGELOG.md index 3ad27e1e8..d515ce34c 100644 --- a/packages/server/core/CHANGELOG.md +++ b/packages/server/core/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-core +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/core/package.json b/packages/server/core/package.json index 54bb15aa4..d24bf1cd6 100644 --- a/packages/server/core/package.json +++ b/packages/server/core/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-core", "description": "Server-specific utilities for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -27,8 +27,8 @@ "update": "npx npm-check-updates -u && npm update" }, "devDependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", @@ -52,6 +52,6 @@ } ], "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } } diff --git a/packages/server/destinations/amplitude/CHANGELOG.md b/packages/server/destinations/amplitude/CHANGELOG.md index 34e55c3a1..d1d00b22a 100644 --- a/packages/server/destinations/amplitude/CHANGELOG.md +++ b/packages/server/destinations/amplitude/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-amplitude +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/amplitude/package.json b/packages/server/destinations/amplitude/package.json index 9bd425eaf..8a140ce57 100644 --- a/packages/server/destinations/amplitude/package.json +++ b/packages/server/destinations/amplitude/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-amplitude", "description": "Amplitude server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@amplitude/analytics-node": "^1.5.53", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/api/CHANGELOG.md b/packages/server/destinations/api/CHANGELOG.md index 045f74f04..ce2c4a784 100644 --- a/packages/server/destinations/api/CHANGELOG.md +++ b/packages/server/destinations/api/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-api +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/api/package.json b/packages/server/destinations/api/package.json index 75bef876d..86cbf3764 100644 --- a/packages/server/destinations/api/package.json +++ b/packages/server/destinations/api/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-api", "description": "API server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,8 +38,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": {}, "repository": { diff --git a/packages/server/destinations/aws/CHANGELOG.md b/packages/server/destinations/aws/CHANGELOG.md index cf2c71ac2..c6598ba5b 100644 --- a/packages/server/destinations/aws/CHANGELOG.md +++ b/packages/server/destinations/aws/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-aws +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/aws/package.json b/packages/server/destinations/aws/package.json index 52420433e..d807f612c 100644 --- a/packages/server/destinations/aws/package.json +++ b/packages/server/destinations/aws/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-aws", "description": "AWS server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -38,8 +38,8 @@ "@aws-sdk/client-firehose": "^3.952.0", "@aws-sdk/client-sns": "^3.952.0", "@aws-sdk/client-sts": "^3.952.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": {}, "repository": { diff --git a/packages/server/destinations/bing/CHANGELOG.md b/packages/server/destinations/bing/CHANGELOG.md index fca53e722..d93f945d2 100644 --- a/packages/server/destinations/bing/CHANGELOG.md +++ b/packages/server/destinations/bing/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-meta +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/bing/package.json b/packages/server/destinations/bing/package.json index eb33db712..2dd8bcaf3 100644 --- a/packages/server/destinations/bing/package.json +++ b/packages/server/destinations/bing/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-bing", "description": "Microsoft Advertising (Bing UET CAPI) server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/criteo/CHANGELOG.md b/packages/server/destinations/criteo/CHANGELOG.md index 4f28a31a2..db6daa3a3 100644 --- a/packages/server/destinations/criteo/CHANGELOG.md +++ b/packages/server/destinations/criteo/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-criteo +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/criteo/package.json b/packages/server/destinations/criteo/package.json index 3f33db31c..4a9595255 100644 --- a/packages/server/destinations/criteo/package.json +++ b/packages/server/destinations/criteo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-criteo", "description": "Criteo Events API server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/customerio/CHANGELOG.md b/packages/server/destinations/customerio/CHANGELOG.md index bf5276d89..90a8497c1 100644 --- a/packages/server/destinations/customerio/CHANGELOG.md +++ b/packages/server/destinations/customerio/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-customerio +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/customerio/package.json b/packages/server/destinations/customerio/package.json index 68fda9f09..b8cdfdda6 100644 --- a/packages/server/destinations/customerio/package.json +++ b/packages/server/destinations/customerio/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-customerio", "description": "Customer.io messaging automation server destination for walkerOS (customerio-node, Track + Transactional API)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "customerio-node": "^4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/datamanager/CHANGELOG.md b/packages/server/destinations/datamanager/CHANGELOG.md index f5dbef609..4546ac452 100644 --- a/packages/server/destinations/datamanager/CHANGELOG.md +++ b/packages/server/destinations/datamanager/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-datamanager +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/server/destinations/datamanager/package.json b/packages/server/destinations/datamanager/package.json index 4971a3fa3..0841bcd42 100644 --- a/packages/server/destinations/datamanager/package.json +++ b/packages/server/destinations/datamanager/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-datamanager", "description": "Google Data Manager server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,12 +35,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "google-auth-library": "^10.5.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/file/CHANGELOG.md b/packages/server/destinations/file/CHANGELOG.md index 94078ecf7..4c869b629 100644 --- a/packages/server/destinations/file/CHANGELOG.md +++ b/packages/server/destinations/file/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-file +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/file/package.json b/packages/server/destinations/file/package.json index 86c9d1dcb..cd702cb98 100644 --- a/packages/server/destinations/file/package.json +++ b/packages/server/destinations/file/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-file", "description": "Local file sink for walkerOS server flows (JSONL, TSV, CSV)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/gcp/CHANGELOG.md b/packages/server/destinations/gcp/CHANGELOG.md index 6b0f5c308..8c4226732 100644 --- a/packages/server/destinations/gcp/CHANGELOG.md +++ b/packages/server/destinations/gcp/CHANGELOG.md @@ -1,5 +1,26 @@ # @walkeros/server-destination-gcp +## 4.2.1 + +### Patch Changes + +- 96c791a: The BigQuery destination now applies `config.credentials` to the + Storage Write client that performs event writes, not just the query client. + Event writes from the configured service account now succeed on non-Google + Cloud runtimes instead of failing with a credentials error. Both clients + resolve credentials the same way, so a destination always authenticates as a + single identity. +- 8afb7cc: Capture BigQuery Storage Write stream errors so a broken writer + routes events to the dead-letter queue instead of crashing the process, and + re-open a broken writer automatically on the next event. +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/server/destinations/gcp/package.json b/packages/server/destinations/gcp/package.json index e2ec6827d..f8a6b341a 100644 --- a/packages/server/destinations/gcp/package.json +++ b/packages/server/destinations/gcp/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-gcp", "description": "Google Cloud Platform server destinations for walkerOS (BigQuery, Pub/Sub)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -33,8 +33,8 @@ "@google-cloud/bigquery": "^8.1.1", "@google-cloud/bigquery-storage": "^5.1.0", "@google-cloud/pubsub": "^5.3.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": {}, "repository": { diff --git a/packages/server/destinations/hubspot/CHANGELOG.md b/packages/server/destinations/hubspot/CHANGELOG.md index 0a363d3a7..91771dd2a 100644 --- a/packages/server/destinations/hubspot/CHANGELOG.md +++ b/packages/server/destinations/hubspot/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-hubspot +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/hubspot/package.json b/packages/server/destinations/hubspot/package.json index 873785dfe..55db54e1c 100644 --- a/packages/server/destinations/hubspot/package.json +++ b/packages/server/destinations/hubspot/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-hubspot", "description": "HubSpot CRM server destination for walkerOS (@hubspot/api-client, custom events + contact upsert)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@hubspot/api-client": "^13.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/kafka/CHANGELOG.md b/packages/server/destinations/kafka/CHANGELOG.md index 616a748c4..26bb96001 100644 --- a/packages/server/destinations/kafka/CHANGELOG.md +++ b/packages/server/destinations/kafka/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-kafka +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/kafka/package.json b/packages/server/destinations/kafka/package.json index 241f955f8..5d17f3f7a 100644 --- a/packages/server/destinations/kafka/package.json +++ b/packages/server/destinations/kafka/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-kafka", "description": "Apache Kafka server destination for walkerOS (kafkajs, JSON serialization, GZIP compression)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "kafkajs": "^2.2.4", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/klaviyo/CHANGELOG.md b/packages/server/destinations/klaviyo/CHANGELOG.md index db8478d61..3ee2cf8c5 100644 --- a/packages/server/destinations/klaviyo/CHANGELOG.md +++ b/packages/server/destinations/klaviyo/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-klaviyo +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/klaviyo/package.json b/packages/server/destinations/klaviyo/package.json index aaf2538f5..bbc8b0e3c 100644 --- a/packages/server/destinations/klaviyo/package.json +++ b/packages/server/destinations/klaviyo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-klaviyo", "description": "Klaviyo marketing automation server destination for walkerOS (klaviyo-api SDK, events + profile upserts)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "klaviyo-api": "^22.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/linkedin/CHANGELOG.md b/packages/server/destinations/linkedin/CHANGELOG.md index b306e910b..00a03e89d 100644 --- a/packages/server/destinations/linkedin/CHANGELOG.md +++ b/packages/server/destinations/linkedin/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-linkedin +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/linkedin/package.json b/packages/server/destinations/linkedin/package.json index 540164136..e9d7afb32 100644 --- a/packages/server/destinations/linkedin/package.json +++ b/packages/server/destinations/linkedin/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-linkedin", "description": "LinkedIn Conversions API server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/meta/CHANGELOG.md b/packages/server/destinations/meta/CHANGELOG.md index c2ffcb220..68ea712d2 100644 --- a/packages/server/destinations/meta/CHANGELOG.md +++ b/packages/server/destinations/meta/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-meta +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/meta/package.json b/packages/server/destinations/meta/package.json index 268e6de09..12b482891 100644 --- a/packages/server/destinations/meta/package.json +++ b/packages/server/destinations/meta/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-meta", "description": "Meta server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/mixpanel/CHANGELOG.md b/packages/server/destinations/mixpanel/CHANGELOG.md index d28f7df44..ce842e83b 100644 --- a/packages/server/destinations/mixpanel/CHANGELOG.md +++ b/packages/server/destinations/mixpanel/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-mixpanel +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/mixpanel/package.json b/packages/server/destinations/mixpanel/package.json index aeca4fc7f..c581af44b 100644 --- a/packages/server/destinations/mixpanel/package.json +++ b/packages/server/destinations/mixpanel/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-mixpanel", "description": "Mixpanel server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,12 +35,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "mixpanel": "^0.22.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/mparticle/CHANGELOG.md b/packages/server/destinations/mparticle/CHANGELOG.md index dbb3922ff..77ffb1612 100644 --- a/packages/server/destinations/mparticle/CHANGELOG.md +++ b/packages/server/destinations/mparticle/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-mparticle +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/mparticle/package.json b/packages/server/destinations/mparticle/package.json index 9e8039cc6..5971f278f 100644 --- a/packages/server/destinations/mparticle/package.json +++ b/packages/server/destinations/mparticle/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-mparticle", "description": "mParticle server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/pinterest/CHANGELOG.md b/packages/server/destinations/pinterest/CHANGELOG.md index b2fce6633..156aa83d9 100644 --- a/packages/server/destinations/pinterest/CHANGELOG.md +++ b/packages/server/destinations/pinterest/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-pinterest +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/pinterest/package.json b/packages/server/destinations/pinterest/package.json index ae1d0c394..aff466353 100644 --- a/packages/server/destinations/pinterest/package.json +++ b/packages/server/destinations/pinterest/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-pinterest", "description": "Pinterest server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/posthog/CHANGELOG.md b/packages/server/destinations/posthog/CHANGELOG.md index 6dbc62679..96a7a453b 100644 --- a/packages/server/destinations/posthog/CHANGELOG.md +++ b/packages/server/destinations/posthog/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-posthog +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/posthog/package.json b/packages/server/destinations/posthog/package.json index 43577aa7a..766c39469 100644 --- a/packages/server/destinations/posthog/package.json +++ b/packages/server/destinations/posthog/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-posthog", "description": "PostHog server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,12 +35,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "posthog-node": "^5.0.0" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/reddit/CHANGELOG.md b/packages/server/destinations/reddit/CHANGELOG.md index 4d8417a4b..6bd354a10 100644 --- a/packages/server/destinations/reddit/CHANGELOG.md +++ b/packages/server/destinations/reddit/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-reddit +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/reddit/package.json b/packages/server/destinations/reddit/package.json index 000ff53a1..8fbd42f7e 100644 --- a/packages/server/destinations/reddit/package.json +++ b/packages/server/destinations/reddit/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-reddit", "description": "Reddit server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/redis/CHANGELOG.md b/packages/server/destinations/redis/CHANGELOG.md index 5b8c7e7a3..ddb91c122 100644 --- a/packages/server/destinations/redis/CHANGELOG.md +++ b/packages/server/destinations/redis/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-redis +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/redis/package.json b/packages/server/destinations/redis/package.json index d7d100f9c..c8b4bd597 100644 --- a/packages/server/destinations/redis/package.json +++ b/packages/server/destinations/redis/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-redis", "description": "Redis Streams server destination for walkerOS (ioredis, XADD, pipeline batching)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "ioredis": "^5.10.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/rudderstack/CHANGELOG.md b/packages/server/destinations/rudderstack/CHANGELOG.md index c6ce5ef3d..e6315c490 100644 --- a/packages/server/destinations/rudderstack/CHANGELOG.md +++ b/packages/server/destinations/rudderstack/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-rudderstack +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/rudderstack/package.json b/packages/server/destinations/rudderstack/package.json index 0c1fa69cc..6e87b7b87 100644 --- a/packages/server/destinations/rudderstack/package.json +++ b/packages/server/destinations/rudderstack/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-rudderstack", "description": "RudderStack CDP server destination for walkerOS (@rudderstack/rudder-sdk-node, full Segment Spec)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@rudderstack/rudder-sdk-node": "^3.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/segment/CHANGELOG.md b/packages/server/destinations/segment/CHANGELOG.md index d19df7618..224b22dab 100644 --- a/packages/server/destinations/segment/CHANGELOG.md +++ b/packages/server/destinations/segment/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-segment +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/segment/package.json b/packages/server/destinations/segment/package.json index 0e23c629f..631e35dce 100644 --- a/packages/server/destinations/segment/package.json +++ b/packages/server/destinations/segment/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-segment", "description": "Segment CDP server destination for walkerOS (@segment/analytics-node, full Segment Spec)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@segment/analytics-node": "^3.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/slack/CHANGELOG.md b/packages/server/destinations/slack/CHANGELOG.md index 638c03ad0..1f830e992 100644 --- a/packages/server/destinations/slack/CHANGELOG.md +++ b/packages/server/destinations/slack/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-slack +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/slack/package.json b/packages/server/destinations/slack/package.json index b3efca304..a60cbc41e 100644 --- a/packages/server/destinations/slack/package.json +++ b/packages/server/destinations/slack/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-slack", "description": "Slack server destination for walkerOS (Incoming Webhook + @slack/web-api, Block Kit, channel routing, threading, DMs)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@slack/web-api": "^7.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/snapchat/CHANGELOG.md b/packages/server/destinations/snapchat/CHANGELOG.md index f44319333..33593d31a 100644 --- a/packages/server/destinations/snapchat/CHANGELOG.md +++ b/packages/server/destinations/snapchat/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-meta +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/snapchat/package.json b/packages/server/destinations/snapchat/package.json index 9cf791097..58ffbe183 100644 --- a/packages/server/destinations/snapchat/package.json +++ b/packages/server/destinations/snapchat/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-snapchat", "description": "Snapchat Conversions API server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/sqlite/CHANGELOG.md b/packages/server/destinations/sqlite/CHANGELOG.md index 882318933..046c5add8 100644 --- a/packages/server/destinations/sqlite/CHANGELOG.md +++ b/packages/server/destinations/sqlite/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-sqlite +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/sqlite/package.json b/packages/server/destinations/sqlite/package.json index f7d4365ff..2b36693dd 100644 --- a/packages/server/destinations/sqlite/package.json +++ b/packages/server/destinations/sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-sqlite", "description": "SQLite server destination for walkerOS (local via better-sqlite3, remote via libSQL/Turso)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,8 +38,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "peerDependencies": { "better-sqlite3": "^12.0.0", @@ -54,7 +54,7 @@ } }, "devDependencies": { - "@walkeros/collector": "4.2.0", + "@walkeros/collector": "4.2.1", "better-sqlite3": "^12.0.0", "@libsql/client": "^0.17.0", "@types/better-sqlite3": "^7.6.13" diff --git a/packages/server/destinations/tiktok/CHANGELOG.md b/packages/server/destinations/tiktok/CHANGELOG.md index 2210f6b81..19bfac570 100644 --- a/packages/server/destinations/tiktok/CHANGELOG.md +++ b/packages/server/destinations/tiktok/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-meta +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/tiktok/package.json b/packages/server/destinations/tiktok/package.json index 17f849ae3..150fe0ab0 100644 --- a/packages/server/destinations/tiktok/package.json +++ b/packages/server/destinations/tiktok/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-tiktok", "description": "TikTok Events API server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/twitter/CHANGELOG.md b/packages/server/destinations/twitter/CHANGELOG.md index 8c4e8ac55..69efdbd87 100644 --- a/packages/server/destinations/twitter/CHANGELOG.md +++ b/packages/server/destinations/twitter/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-twitter +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/destinations/twitter/package.json b/packages/server/destinations/twitter/package.json index 784d8bfa3..569aeddb9 100644 --- a/packages/server/destinations/twitter/package.json +++ b/packages/server/destinations/twitter/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-twitter", "description": "X (Twitter) Conversions API server destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "exports": { ".": { @@ -35,12 +35,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0", + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1", "oauth-1.0a": "^2.2.6" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/sources/aws/CHANGELOG.md b/packages/server/sources/aws/CHANGELOG.md index f5488791c..4f14d3e46 100644 --- a/packages/server/sources/aws/CHANGELOG.md +++ b/packages/server/sources/aws/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-source-aws +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/sources/aws/package.json b/packages/server/sources/aws/package.json index 019ca18ed..e1abdfd44 100644 --- a/packages/server/sources/aws/package.json +++ b/packages/server/sources/aws/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-source-aws", "description": "AWS server sources for walkerOS (Lambda, API Gateway, Function URLs)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -23,14 +23,14 @@ "dependencies": { "@aws-sdk/client-sqs": "^3.952.0", "@aws-sdk/client-sns": "^3.952.0", - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "peerDependencies": { "@types/aws-lambda": "^8.10.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.159", - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/sources/express/CHANGELOG.md b/packages/server/sources/express/CHANGELOG.md index 3a0402cc3..1363eca93 100644 --- a/packages/server/sources/express/CHANGELOG.md +++ b/packages/server/sources/express/CHANGELOG.md @@ -1,5 +1,25 @@ # @walkeros/server-source-express +## 4.2.1 + +### Patch Changes + +- 0a8a08b: Add an optional `async` option to the source config + (`Source.Config.async`) for respond-first acknowledgement on + response-producing server sources. The express source now reads `config.async` + (default `true`): a 2xx response means the event was accepted, not yet + delivered. +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/sources/express/package.json b/packages/server/sources/express/package.json index 9a9180d21..b34965f79 100644 --- a/packages/server/sources/express/package.json +++ b/packages/server/sources/express/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-source-express", "description": "Express server source for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -20,8 +20,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", "express": "^5.2.1", "cors": "^2.8.5" }, diff --git a/packages/server/sources/fetch/CHANGELOG.md b/packages/server/sources/fetch/CHANGELOG.md index 5c7917746..27e7ea9bf 100644 --- a/packages/server/sources/fetch/CHANGELOG.md +++ b/packages/server/sources/fetch/CHANGELOG.md @@ -1,5 +1,20 @@ # @walkeros/server-source-fetch +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/sources/fetch/package.json b/packages/server/sources/fetch/package.json index 13ed5853b..664f11145 100644 --- a/packages/server/sources/fetch/package.json +++ b/packages/server/sources/fetch/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-source-fetch", "description": "Web Standard Fetch API source for walkerOS (Cloudflare Workers, Vercel Edge, Deno, Bun)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -20,8 +20,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "devDependencies": {}, "repository": { diff --git a/packages/server/sources/gcp/CHANGELOG.md b/packages/server/sources/gcp/CHANGELOG.md index ee1f2d552..e186b4262 100644 --- a/packages/server/sources/gcp/CHANGELOG.md +++ b/packages/server/sources/gcp/CHANGELOG.md @@ -1,5 +1,20 @@ # @walkeros/server-source-gcp +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/server/sources/gcp/package.json b/packages/server/sources/gcp/package.json index 53210670c..224373acb 100644 --- a/packages/server/sources/gcp/package.json +++ b/packages/server/sources/gcp/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-source-gcp", "description": "Google Cloud Platform server sources for walkerOS (Cloud Functions, Pub/Sub)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -21,8 +21,8 @@ }, "dependencies": { "@google-cloud/pubsub": "^5.3.0", - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "peerDependencies": { "@google-cloud/functions-framework": "^5.0.2" diff --git a/packages/server/stores/fs/CHANGELOG.md b/packages/server/stores/fs/CHANGELOG.md index 575d235f2..4bc22e3f8 100644 --- a/packages/server/stores/fs/CHANGELOG.md +++ b/packages/server/stores/fs/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-store-fs +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/stores/fs/package.json b/packages/server/stores/fs/package.json index 1f7bea177..b6f2e5dd3 100644 --- a/packages/server/stores/fs/package.json +++ b/packages/server/stores/fs/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-store-fs", "description": "Filesystem store for walkerOS server - reads and writes files via the Store interface", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,10 +33,10 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/stores/gcs/CHANGELOG.md b/packages/server/stores/gcs/CHANGELOG.md index 118a74ad7..0570bdc25 100644 --- a/packages/server/stores/gcs/CHANGELOG.md +++ b/packages/server/stores/gcs/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-store-gcs +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/server/stores/gcs/package.json b/packages/server/stores/gcs/package.json index fc186ae2e..3d74e6b6d 100644 --- a/packages/server/stores/gcs/package.json +++ b/packages/server/stores/gcs/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-store-gcs", "description": "Google Cloud Storage for walkerOS server flows", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,7 +33,7 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": {}, "repository": { diff --git a/packages/server/stores/s3/CHANGELOG.md b/packages/server/stores/s3/CHANGELOG.md index e95e9259e..ca2ed7fe9 100644 --- a/packages/server/stores/s3/CHANGELOG.md +++ b/packages/server/stores/s3/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-store-s3 +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/stores/s3/package.json b/packages/server/stores/s3/package.json index 23707e94a..97af6a580 100644 --- a/packages/server/stores/s3/package.json +++ b/packages/server/stores/s3/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-store-s3", "description": "S3-compatible object storage for walkerOS server flows", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,7 +33,7 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", + "@walkeros/core": "4.2.1", "s3mini": "^0.9.1" }, "devDependencies": {}, diff --git a/packages/server/stores/sheets/CHANGELOG.md b/packages/server/stores/sheets/CHANGELOG.md index 7f361bdd5..c0c67ddf7 100644 --- a/packages/server/stores/sheets/CHANGELOG.md +++ b/packages/server/stores/sheets/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-store-sheets +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/server/stores/sheets/package.json b/packages/server/stores/sheets/package.json index 60770d885..c483c21ee 100644 --- a/packages/server/stores/sheets/package.json +++ b/packages/server/stores/sheets/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-store-sheets", "description": "Google Sheets store for walkerOS server flows", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,7 +33,7 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": {}, "repository": { diff --git a/packages/server/transformers/bot/CHANGELOG.md b/packages/server/transformers/bot/CHANGELOG.md index c8829491f..23e4e7b02 100644 --- a/packages/server/transformers/bot/CHANGELOG.md +++ b/packages/server/transformers/bot/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-transformer-bot +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/transformers/bot/package.json b/packages/server/transformers/bot/package.json index 0ec53340b..fe38f3701 100644 --- a/packages/server/transformers/bot/package.json +++ b/packages/server/transformers/bot/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-transformer-bot", "description": "Server-side bot and AI-agent detection transformer for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -28,7 +28,7 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", + "@walkeros/core": "4.2.1", "isbot": "^5.1.39" }, "repository": { diff --git a/packages/server/transformers/file/CHANGELOG.md b/packages/server/transformers/file/CHANGELOG.md index ba4b80e7a..d16d283f1 100644 --- a/packages/server/transformers/file/CHANGELOG.md +++ b/packages/server/transformers/file/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-transformer-file +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/transformers/file/package.json b/packages/server/transformers/file/package.json index 6b23c28ef..893353c51 100644 --- a/packages/server/transformers/file/package.json +++ b/packages/server/transformers/file/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-transformer-file", "description": "File serving transformer for walkerOS - serves static files via pluggable Store backend", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -28,10 +28,10 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/transformers/fingerprint/CHANGELOG.md b/packages/server/transformers/fingerprint/CHANGELOG.md index 17e496272..4eaadf7a1 100644 --- a/packages/server/transformers/fingerprint/CHANGELOG.md +++ b/packages/server/transformers/fingerprint/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-transformer-fingerprint +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/server-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/server/transformers/fingerprint/package.json b/packages/server/transformers/fingerprint/package.json index ff9ab6bc4..51f1890d0 100644 --- a/packages/server/transformers/fingerprint/package.json +++ b/packages/server/transformers/fingerprint/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-transformer-fingerprint", "description": "Fingerprint transformer for walkerOS server - hash configurable fields for session continuity", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -27,8 +27,8 @@ "update": "npx npm-check-updates -u && npm update" }, "devDependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", @@ -60,7 +60,7 @@ } ], "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/server-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/server-core": "4.2.1" } } diff --git a/packages/transformers/demo/CHANGELOG.md b/packages/transformers/demo/CHANGELOG.md index 4e2c0f375..e02741b6d 100644 --- a/packages/transformers/demo/CHANGELOG.md +++ b/packages/transformers/demo/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/transformer-demo +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/transformers/demo/package.json b/packages/transformers/demo/package.json index 33c122b95..6ce1d7b5e 100644 --- a/packages/transformers/demo/package.json +++ b/packages/transformers/demo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/transformer-demo", "description": "Demo transformer for walkerOS - logs and passes through events", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -26,7 +26,7 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/transformers/ga4/CHANGELOG.md b/packages/transformers/ga4/CHANGELOG.md index 726dcd16a..cedf89050 100644 --- a/packages/transformers/ga4/CHANGELOG.md +++ b/packages/transformers/ga4/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/transformer-ga4 +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/transformers/ga4/package.json b/packages/transformers/ga4/package.json index 29b896727..296d0d04c 100644 --- a/packages/transformers/ga4/package.json +++ b/packages/transformers/ga4/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/transformer-ga4", "description": "Decodes GA4 Measurement Protocol v2 (gtag /g/collect) into walkerOS events", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -32,10 +32,10 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/transformers/validate/CHANGELOG.md b/packages/transformers/validate/CHANGELOG.md index 8f02b18c2..192750f8a 100644 --- a/packages/transformers/validate/CHANGELOG.md +++ b/packages/transformers/validate/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/transformer-validate +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/transformers/validate/package.json b/packages/transformers/validate/package.json index 2ae70ed23..1397cafde 100644 --- a/packages/transformers/validate/package.json +++ b/packages/transformers/validate/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/transformer-validate", "description": "JSON Schema contract validation transformer for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,11 +33,11 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.2.0", + "@walkeros/core": "4.2.1", "@cfworker/json-schema": "^4.1.1" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/core/CHANGELOG.md b/packages/web/core/CHANGELOG.md index 404120c19..5e543c870 100644 --- a/packages/web/core/CHANGELOG.md +++ b/packages/web/core/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/web-core +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/web/core/package.json b/packages/web/core/package.json index 5a4736a7d..faa783b3e 100644 --- a/packages/web/core/package.json +++ b/packages/web/core/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-core", "description": "Web-specific utilities for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -27,7 +27,7 @@ "update": "npx npm-check-updates -u && npm update" }, "devDependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", @@ -51,6 +51,6 @@ } ], "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" } } diff --git a/packages/web/destinations/amplitude/CHANGELOG.md b/packages/web/destinations/amplitude/CHANGELOG.md index 38fafdb3e..2596bb36e 100644 --- a/packages/web/destinations/amplitude/CHANGELOG.md +++ b/packages/web/destinations/amplitude/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-amplitude +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/amplitude/package.json b/packages/web/destinations/amplitude/package.json index e8b5a57e5..c985d9641 100644 --- a/packages/web/destinations/amplitude/package.json +++ b/packages/web/destinations/amplitude/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-amplitude", "description": "Amplitude web destination for walkerOS (analytics, session replay, experiments, guides & surveys)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@amplitude/unified": "^1.0.16", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/api/CHANGELOG.md b/packages/web/destinations/api/CHANGELOG.md index f58154d34..5c4f37901 100644 --- a/packages/web/destinations/api/CHANGELOG.md +++ b/packages/web/destinations/api/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-api +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/api/package.json b/packages/web/destinations/api/package.json index 4e16814d0..3f625d396 100644 --- a/packages/web/destinations/api/package.json +++ b/packages/web/destinations/api/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-api", "description": "Web API destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/clarity/CHANGELOG.md b/packages/web/destinations/clarity/CHANGELOG.md index 86c1d881d..514990423 100644 --- a/packages/web/destinations/clarity/CHANGELOG.md +++ b/packages/web/destinations/clarity/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-clarity +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/clarity/package.json b/packages/web/destinations/clarity/package.json index 26df7bf19..fcc481337 100644 --- a/packages/web/destinations/clarity/package.json +++ b/packages/web/destinations/clarity/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-clarity", "description": "Microsoft Clarity web destination for walkerOS (session replay, heatmaps, smart events)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@microsoft/clarity": "^1.0.2", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/d8a/CHANGELOG.md b/packages/web/destinations/d8a/CHANGELOG.md index b68fd9913..fe54dbb01 100644 --- a/packages/web/destinations/d8a/CHANGELOG.md +++ b/packages/web/destinations/d8a/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-d8a +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/d8a/package.json b/packages/web/destinations/d8a/package.json index 9970cd2b7..558cc144b 100644 --- a/packages/web/destinations/d8a/package.json +++ b/packages/web/destinations/d8a/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-d8a", "description": "d8a web destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ }, "dependencies": { "@d8a-tech/wt": "^1.2.1", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/fullstory/CHANGELOG.md b/packages/web/destinations/fullstory/CHANGELOG.md index 2b9451344..4308a9004 100644 --- a/packages/web/destinations/fullstory/CHANGELOG.md +++ b/packages/web/destinations/fullstory/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-fullstory +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/fullstory/package.json b/packages/web/destinations/fullstory/package.json index 96ea29ebc..7f0c3ca81 100644 --- a/packages/web/destinations/fullstory/package.json +++ b/packages/web/destinations/fullstory/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-fullstory", "description": "FullStory web destination for walkerOS (session replay, custom events, user/page properties)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@fullstory/browser": "^2.0.8", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/gtag/CHANGELOG.md b/packages/web/destinations/gtag/CHANGELOG.md index 1824dd23c..81e497833 100644 --- a/packages/web/destinations/gtag/CHANGELOG.md +++ b/packages/web/destinations/gtag/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-gtag +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/gtag/package.json b/packages/web/destinations/gtag/package.json index 9c5e0e07d..a1fc01e60 100644 --- a/packages/web/destinations/gtag/package.json +++ b/packages/web/destinations/gtag/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-gtag", "description": "Unified Google destination for walkerOS (GA4, Ads, GTM)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,8 +38,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/heap/CHANGELOG.md b/packages/web/destinations/heap/CHANGELOG.md index c7a0e0128..2145b5fa6 100644 --- a/packages/web/destinations/heap/CHANGELOG.md +++ b/packages/web/destinations/heap/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-heap +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/heap/package.json b/packages/web/destinations/heap/package.json index 7b1e68556..8868658c3 100644 --- a/packages/web/destinations/heap/package.json +++ b/packages/web/destinations/heap/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-heap", "description": "Heap web destination for walkerOS (product analytics, auto-capture)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/hotjar/CHANGELOG.md b/packages/web/destinations/hotjar/CHANGELOG.md index 904125f57..6991edcec 100644 --- a/packages/web/destinations/hotjar/CHANGELOG.md +++ b/packages/web/destinations/hotjar/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-hotjar +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/hotjar/package.json b/packages/web/destinations/hotjar/package.json index a7be071c8..a34c45f7b 100644 --- a/packages/web/destinations/hotjar/package.json +++ b/packages/web/destinations/hotjar/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-hotjar", "description": "Hotjar web destination for walkerOS (session replay, heatmaps, surveys)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@hotjar/browser": "^1.0.9", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/linkedin/CHANGELOG.md b/packages/web/destinations/linkedin/CHANGELOG.md index 07dd162a8..455521310 100644 --- a/packages/web/destinations/linkedin/CHANGELOG.md +++ b/packages/web/destinations/linkedin/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-linkedin +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/linkedin/package.json b/packages/web/destinations/linkedin/package.json index d10701ad9..9d3f29d54 100644 --- a/packages/web/destinations/linkedin/package.json +++ b/packages/web/destinations/linkedin/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-linkedin", "description": "LinkedIn Insight Tag web destination for walkerOS (conversion tracking, retargeting, demographic insights)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/matomo/CHANGELOG.md b/packages/web/destinations/matomo/CHANGELOG.md index 2a99ca7a2..76fb03a7c 100644 --- a/packages/web/destinations/matomo/CHANGELOG.md +++ b/packages/web/destinations/matomo/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-matomo +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/matomo/package.json b/packages/web/destinations/matomo/package.json index eb82f068b..c8840710a 100644 --- a/packages/web/destinations/matomo/package.json +++ b/packages/web/destinations/matomo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-matomo", "description": "Matomo web destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/meta/CHANGELOG.md b/packages/web/destinations/meta/CHANGELOG.md index 74b6bac06..b8e091025 100644 --- a/packages/web/destinations/meta/CHANGELOG.md +++ b/packages/web/destinations/meta/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-meta +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/meta/package.json b/packages/web/destinations/meta/package.json index c7177a352..e6c1e1322 100644 --- a/packages/web/destinations/meta/package.json +++ b/packages/web/destinations/meta/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-meta", "description": "Meta pixel web destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,12 +38,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { "@types/facebook-pixel": "^0.0.31", - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/mixpanel/CHANGELOG.md b/packages/web/destinations/mixpanel/CHANGELOG.md index 329466c6c..af6bc2e20 100644 --- a/packages/web/destinations/mixpanel/CHANGELOG.md +++ b/packages/web/destinations/mixpanel/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-mixpanel +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/mixpanel/package.json b/packages/web/destinations/mixpanel/package.json index cd0b11a68..9b64b8007 100644 --- a/packages/web/destinations/mixpanel/package.json +++ b/packages/web/destinations/mixpanel/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-mixpanel", "description": "Mixpanel web destination for walkerOS (events, people, groups, consent)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,13 +38,13 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", + "@walkeros/core": "4.2.1", "mixpanel-browser": "^2.78.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/web-core": "4.2.1" }, "devDependencies": { "@types/mixpanel-browser": "^2.50.0", - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/optimizely/CHANGELOG.md b/packages/web/destinations/optimizely/CHANGELOG.md index 0d07dae8c..b8ff87225 100644 --- a/packages/web/destinations/optimizely/CHANGELOG.md +++ b/packages/web/destinations/optimizely/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-optimizely +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/optimizely/package.json b/packages/web/destinations/optimizely/package.json index 10b02221e..953d838b8 100644 --- a/packages/web/destinations/optimizely/package.json +++ b/packages/web/destinations/optimizely/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-optimizely", "description": "Optimizely Feature Experimentation web destination for walkerOS (conversion tracking, revenue metrics, user targeting)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@optimizely/optimizely-sdk": "^6.0.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/piano/CHANGELOG.md b/packages/web/destinations/piano/CHANGELOG.md index 9b5bd8027..d4ccda661 100644 --- a/packages/web/destinations/piano/CHANGELOG.md +++ b/packages/web/destinations/piano/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-piano +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/piano/package.json b/packages/web/destinations/piano/package.json index 938850776..caaeb8e47 100644 --- a/packages/web/destinations/piano/package.json +++ b/packages/web/destinations/piano/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-piano", "description": "Piano Analytics web destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/pinterest/CHANGELOG.md b/packages/web/destinations/pinterest/CHANGELOG.md index 193fbd817..43cb998c6 100644 --- a/packages/web/destinations/pinterest/CHANGELOG.md +++ b/packages/web/destinations/pinterest/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-pinterest +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/pinterest/package.json b/packages/web/destinations/pinterest/package.json index cd31d6a42..b69d1af01 100644 --- a/packages/web/destinations/pinterest/package.json +++ b/packages/web/destinations/pinterest/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-pinterest", "description": "Pinterest Tag web destination for walkerOS (conversion tracking, enhanced matching, audience building)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/piwikpro/CHANGELOG.md b/packages/web/destinations/piwikpro/CHANGELOG.md index b28303d73..467936ee1 100644 --- a/packages/web/destinations/piwikpro/CHANGELOG.md +++ b/packages/web/destinations/piwikpro/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-piwikpro +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/piwikpro/package.json b/packages/web/destinations/piwikpro/package.json index 34544c1e3..6f2a8bc2f 100644 --- a/packages/web/destinations/piwikpro/package.json +++ b/packages/web/destinations/piwikpro/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-piwikpro", "description": "Piwik PRO destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/plausible/CHANGELOG.md b/packages/web/destinations/plausible/CHANGELOG.md index 8b0974ecc..e3ac4dd33 100644 --- a/packages/web/destinations/plausible/CHANGELOG.md +++ b/packages/web/destinations/plausible/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-plausible +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/plausible/package.json b/packages/web/destinations/plausible/package.json index 597905086..3c4395d1e 100644 --- a/packages/web/destinations/plausible/package.json +++ b/packages/web/destinations/plausible/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-plausible", "description": "Plausible web destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/posthog/CHANGELOG.md b/packages/web/destinations/posthog/CHANGELOG.md index c36d824bd..2bc8f3b58 100644 --- a/packages/web/destinations/posthog/CHANGELOG.md +++ b/packages/web/destinations/posthog/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-posthog +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/posthog/package.json b/packages/web/destinations/posthog/package.json index fde2db6e2..a33fbb524 100644 --- a/packages/web/destinations/posthog/package.json +++ b/packages/web/destinations/posthog/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-posthog", "description": "PostHog web destination for walkerOS (product analytics, session replay, feature flags, surveys)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,12 +38,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", + "@walkeros/core": "4.2.1", "posthog-js": "^1.367.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/segment/CHANGELOG.md b/packages/web/destinations/segment/CHANGELOG.md index b60a20ad4..97a29d8e8 100644 --- a/packages/web/destinations/segment/CHANGELOG.md +++ b/packages/web/destinations/segment/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-segment +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/segment/package.json b/packages/web/destinations/segment/package.json index 22872ddc1..8e49de0ed 100644 --- a/packages/web/destinations/segment/package.json +++ b/packages/web/destinations/segment/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-segment", "description": "Segment CDP web destination for walkerOS (@segment/analytics-next, full Segment Spec)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@segment/analytics-next": "^1.82.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/snowplow/CHANGELOG.md b/packages/web/destinations/snowplow/CHANGELOG.md index 08f8a871b..7359e1a55 100644 --- a/packages/web/destinations/snowplow/CHANGELOG.md +++ b/packages/web/destinations/snowplow/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-snowplow +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/snowplow/package.json b/packages/web/destinations/snowplow/package.json index d5ea91a13..908f354c8 100644 --- a/packages/web/destinations/snowplow/package.json +++ b/packages/web/destinations/snowplow/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-snowplow", "description": "Snowplow web destination for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,12 +38,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/config": "4.2.0", + "@walkeros/collector": "4.2.1", + "@walkeros/config": "4.2.1", "@snowplow/browser-tracker-core": "^4.6.8", "@snowplow/browser-plugin-snowplow-ecommerce": "^4.6.8" }, diff --git a/packages/web/destinations/tiktok/CHANGELOG.md b/packages/web/destinations/tiktok/CHANGELOG.md index 87c91e18b..c747f264e 100644 --- a/packages/web/destinations/tiktok/CHANGELOG.md +++ b/packages/web/destinations/tiktok/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/web-destination-tiktok +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/destinations/tiktok/package.json b/packages/web/destinations/tiktok/package.json index 934213e4e..f5ea9b659 100644 --- a/packages/web/destinations/tiktok/package.json +++ b/packages/web/destinations/tiktok/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-tiktok", "description": "TikTok Pixel web destination for walkerOS (conversion tracking, Advanced Matching)", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/sources/browser/CHANGELOG.md b/packages/web/sources/browser/CHANGELOG.md index df7e71100..52265deb7 100644 --- a/packages/web/sources/browser/CHANGELOG.md +++ b/packages/web/sources/browser/CHANGELOG.md @@ -1,5 +1,21 @@ # @walkeros/web-source-browser +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Minor Changes diff --git a/packages/web/sources/browser/package.json b/packages/web/sources/browser/package.json index 0cfcee66a..03b782cea 100644 --- a/packages/web/sources/browser/package.json +++ b/packages/web/sources/browser/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-browser", "description": "Browser DOM source for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,9 +33,9 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/sources/cmps/cookiefirst/CHANGELOG.md b/packages/web/sources/cmps/cookiefirst/CHANGELOG.md index a9d791a7d..76665679f 100644 --- a/packages/web/sources/cmps/cookiefirst/CHANGELOG.md +++ b/packages/web/sources/cmps/cookiefirst/CHANGELOG.md @@ -1,5 +1,20 @@ # @walkeros/web-source-cmp-cookiefirst +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/sources/cmps/cookiefirst/package.json b/packages/web/sources/cmps/cookiefirst/package.json index e0aac83a4..fe076969a 100644 --- a/packages/web/sources/cmps/cookiefirst/package.json +++ b/packages/web/sources/cmps/cookiefirst/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-cmp-cookiefirst", "description": "CookieFirst consent management source for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "walkerOS": { "type": "source", @@ -46,8 +46,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/collector": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/collector": "4.2.1" }, "devDependencies": {}, "repository": { diff --git a/packages/web/sources/cmps/cookiepro/CHANGELOG.md b/packages/web/sources/cmps/cookiepro/CHANGELOG.md index e5d40809a..0b0dbf018 100644 --- a/packages/web/sources/cmps/cookiepro/CHANGELOG.md +++ b/packages/web/sources/cmps/cookiepro/CHANGELOG.md @@ -1,5 +1,20 @@ # @walkeros/web-source-cmp-cookiepro +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/sources/cmps/cookiepro/package.json b/packages/web/sources/cmps/cookiepro/package.json index 36cc18761..e4248f367 100644 --- a/packages/web/sources/cmps/cookiepro/package.json +++ b/packages/web/sources/cmps/cookiepro/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-cmp-cookiepro", "description": "CookiePro/OneTrust consent management source for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "walkerOS": { "type": "source", @@ -46,8 +46,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/collector": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/collector": "4.2.1" }, "devDependencies": {}, "repository": { diff --git a/packages/web/sources/cmps/usercentrics/CHANGELOG.md b/packages/web/sources/cmps/usercentrics/CHANGELOG.md index a697f2f89..86ad69256 100644 --- a/packages/web/sources/cmps/usercentrics/CHANGELOG.md +++ b/packages/web/sources/cmps/usercentrics/CHANGELOG.md @@ -1,5 +1,26 @@ # @walkeros/web-source-cmp-usercentrics +## 4.2.1 + +### Patch Changes + +- ec84331: Use the official Usercentrics events (UC_UI_INITIALIZED, + UC_UI_CMP_EVENT) and consent getters so a returning visitor's prior choice is + applied on load and first-visit defaults stay suppressed under explicitOnly. + The configurable eventName data-layer setting is removed; the source now uses + the always-emitted official events. Fixes consent-change events being dropped + on the current Usercentrics Web CMP. +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/sources/cmps/usercentrics/package.json b/packages/web/sources/cmps/usercentrics/package.json index 8ad359610..c432f38c5 100644 --- a/packages/web/sources/cmps/usercentrics/package.json +++ b/packages/web/sources/cmps/usercentrics/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-cmp-usercentrics", "description": "Usercentrics consent management source for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "walkerOS": { "type": "source", @@ -46,8 +46,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/collector": "4.2.0", - "@walkeros/core": "4.2.0" + "@walkeros/collector": "4.2.1", + "@walkeros/core": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/sources/dataLayer/CHANGELOG.md b/packages/web/sources/dataLayer/CHANGELOG.md index 637d485ca..65750d5be 100644 --- a/packages/web/sources/dataLayer/CHANGELOG.md +++ b/packages/web/sources/dataLayer/CHANGELOG.md @@ -1,5 +1,20 @@ # @walkeros/web-source-datalayer +## 4.2.1 + +### Patch Changes + +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/sources/dataLayer/package.json b/packages/web/sources/dataLayer/package.json index 26b2cba46..a56dcbff5 100644 --- a/packages/web/sources/dataLayer/package.json +++ b/packages/web/sources/dataLayer/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-datalayer", "description": "DataLayer source for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,8 +38,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/collector": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/collector": "4.2.1" }, "devDependencies": { "@types/gtag.js": "^0.0.20" diff --git a/packages/web/sources/demo/CHANGELOG.md b/packages/web/sources/demo/CHANGELOG.md index dbb8ffddf..afc3306af 100644 --- a/packages/web/sources/demo/CHANGELOG.md +++ b/packages/web/sources/demo/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/source-demo +## 4.2.1 + +### Patch Changes + +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/sources/demo/package.json b/packages/web/sources/demo/package.json index b9ad40155..b735c6452 100644 --- a/packages/web/sources/demo/package.json +++ b/packages/web/sources/demo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/source-demo", "description": "Demo source for walkerOS - generates events from config", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,10 +39,10 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.2.0" + "@walkeros/core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/sources/session/CHANGELOG.md b/packages/web/sources/session/CHANGELOG.md index 51ff5fad1..c272b9079 100644 --- a/packages/web/sources/session/CHANGELOG.md +++ b/packages/web/sources/session/CHANGELOG.md @@ -1,5 +1,27 @@ # @walkeros/web-source-session +## 4.2.1 + +### Patch Changes + +- 31c6858: `getId` now draws from the platform's cryptographic random source + (`crypto.getRandomValues`) when available, with unbiased character sampling + and a `Math.random` fallback. Session and device ids generated by the session + source are now longer for a much wider collision margin. +- ec84331: Fix `session start` being dropped when the collector starts with + `run: false` and no consent requirement. Without a consent rule the source + emitted during init, before the collector was allowed, so the event never + reached destinations. The emit now waits for the run lifecycle, matching the + browser source's page view timing, so it lands reliably once the collector + runs. +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] + - @walkeros/core@4.2.1 + - @walkeros/web-core@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/packages/web/sources/session/package.json b/packages/web/sources/session/package.json index 409144f3a..684841e9e 100644 --- a/packages/web/sources/session/package.json +++ b/packages/web/sources/session/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-session", "description": "Session source for walkerOS", - "version": "4.2.0", + "version": "4.2.1", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,11 +33,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.2.0", - "@walkeros/web-core": "4.2.0" + "@walkeros/core": "4.2.1", + "@walkeros/web-core": "4.2.1" }, "devDependencies": { - "@walkeros/collector": "4.2.0" + "@walkeros/collector": "4.2.1" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 2b06f3e95..cb1836454 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -1,5 +1,91 @@ # @walkeros/website +## 4.2.1 + +### Patch Changes + +- Updated dependencies [96c791a] +- Updated dependencies [bd9188d] +- Updated dependencies [d8aebd1] +- Updated dependencies [5cbcd23] +- Updated dependencies [31c6858] +- Updated dependencies [d1b41ca] +- Updated dependencies [ec84331] +- Updated dependencies [0a8a08b] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] +- Updated dependencies [8afb7cc] +- Updated dependencies [ec84331] + - @walkeros/server-destination-gcp@4.2.1 + - @walkeros/collector@4.2.1 + - @walkeros/core@4.2.1 + - @walkeros/web-source-session@4.2.1 + - @walkeros/server-source-express@4.2.1 + - @walkeros/web-source-cmp-usercentrics@4.2.1 + - @walkeros/explorer@4.2.1 + - @walkeros/walker.js@4.2.1 + - @walkeros/server-destination-amplitude@4.2.1 + - @walkeros/server-destination-bing@4.2.1 + - @walkeros/server-destination-criteo@4.2.1 + - @walkeros/server-destination-customerio@4.2.1 + - @walkeros/server-destination-datamanager@4.2.1 + - @walkeros/server-destination-file@4.2.1 + - @walkeros/server-destination-hubspot@4.2.1 + - @walkeros/server-destination-kafka@4.2.1 + - @walkeros/server-destination-klaviyo@4.2.1 + - @walkeros/server-destination-linkedin@4.2.1 + - @walkeros/server-destination-meta@4.2.1 + - @walkeros/server-destination-mixpanel@4.2.1 + - @walkeros/server-destination-mparticle@4.2.1 + - @walkeros/server-destination-pinterest@4.2.1 + - @walkeros/server-destination-posthog@4.2.1 + - @walkeros/server-destination-reddit@4.2.1 + - @walkeros/server-destination-redis@4.2.1 + - @walkeros/server-destination-rudderstack@4.2.1 + - @walkeros/server-destination-segment@4.2.1 + - @walkeros/server-destination-slack@4.2.1 + - @walkeros/server-destination-snapchat@4.2.1 + - @walkeros/server-destination-sqlite@4.2.1 + - @walkeros/server-destination-tiktok@4.2.1 + - @walkeros/server-destination-twitter@4.2.1 + - @walkeros/server-source-aws@4.2.1 + - @walkeros/server-source-fetch@4.2.1 + - @walkeros/server-source-gcp@4.2.1 + - @walkeros/web-destination-amplitude@4.2.1 + - @walkeros/web-destination-api@4.2.1 + - @walkeros/web-destination-clarity@4.2.1 + - @walkeros/web-destination-d8a@4.2.1 + - @walkeros/web-destination-fullstory@4.2.1 + - @walkeros/web-destination-heap@4.2.1 + - @walkeros/web-destination-hotjar@4.2.1 + - @walkeros/web-destination-linkedin@4.2.1 + - @walkeros/web-destination-matomo@4.2.1 + - @walkeros/web-destination-meta@4.2.1 + - @walkeros/web-destination-mixpanel@4.2.1 + - @walkeros/web-destination-optimizely@4.2.1 + - @walkeros/web-destination-pinterest@4.2.1 + - @walkeros/web-destination-piwikpro@4.2.1 + - @walkeros/web-destination-plausible@4.2.1 + - @walkeros/web-destination-posthog@4.2.1 + - @walkeros/web-destination-segment@4.2.1 + - @walkeros/web-destination-snowplow@4.2.1 + - @walkeros/web-destination-tiktok@4.2.1 + - @walkeros/web-source-browser@4.2.1 + - @walkeros/web-source-cmp-cookiefirst@4.2.1 + - @walkeros/web-source-cmp-cookiepro@4.2.1 + - @walkeros/web-source-datalayer@4.2.1 + - @walkeros/server-destination-api@4.2.1 + - @walkeros/server-destination-aws@4.2.1 + - @walkeros/server-store-fs@4.2.1 + - @walkeros/server-store-gcs@4.2.1 + - @walkeros/server-store-s3@4.2.1 + - @walkeros/server-store-sheets@4.2.1 + - @walkeros/server-transformer-bot@4.2.1 + - @walkeros/server-transformer-file@4.2.1 + - @walkeros/server-transformer-fingerprint@4.2.1 + - @walkeros/transformer-ga4@4.2.1 + - @walkeros/web-destination-gtag@4.2.1 + ## 4.2.0 ### Patch Changes diff --git a/website/package.json b/website/package.json index 35e123df6..39bff6378 100644 --- a/website/package.json +++ b/website/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/website", - "version": "4.2.0", + "version": "4.2.1", "private": true, "scripts": { "docusaurus": "docusaurus", @@ -20,75 +20,75 @@ "@docusaurus/theme-live-codeblock": "^3.9.2", "@docusaurus/theme-mermaid": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", - "@walkeros/collector": "^4.2.0", - "@walkeros/core": "^4.2.0", - "@walkeros/explorer": "^4.2.0", - "@walkeros/server-destination-amplitude": "^4.2.0", - "@walkeros/server-destination-api": "^4.2.0", - "@walkeros/server-destination-aws": "^4.2.0", - "@walkeros/server-destination-bing": "^4.2.0", - "@walkeros/server-destination-criteo": "^4.2.0", - "@walkeros/server-destination-customerio": "^4.2.0", - "@walkeros/server-destination-datamanager": "^4.2.0", - "@walkeros/server-destination-file": "^4.2.0", - "@walkeros/server-destination-gcp": "^4.2.0", - "@walkeros/server-destination-hubspot": "^4.2.0", - "@walkeros/server-destination-kafka": "^4.2.0", - "@walkeros/server-destination-klaviyo": "^4.2.0", - "@walkeros/server-destination-linkedin": "^4.2.0", - "@walkeros/server-destination-meta": "^4.2.0", - "@walkeros/server-destination-mixpanel": "^4.2.0", - "@walkeros/server-destination-mparticle": "^4.2.0", - "@walkeros/server-destination-pinterest": "^4.2.0", - "@walkeros/server-destination-posthog": "^4.2.0", - "@walkeros/server-destination-reddit": "^4.2.0", - "@walkeros/server-destination-redis": "^4.2.0", - "@walkeros/server-destination-rudderstack": "^4.2.0", - "@walkeros/server-destination-segment": "^4.2.0", - "@walkeros/server-destination-slack": "^4.2.0", - "@walkeros/server-destination-snapchat": "^4.2.0", - "@walkeros/server-destination-sqlite": "^4.2.0", - "@walkeros/server-destination-tiktok": "^4.2.0", - "@walkeros/server-destination-twitter": "^4.2.0", - "@walkeros/server-source-aws": "^4.2.0", - "@walkeros/server-source-express": "^4.2.0", - "@walkeros/server-source-fetch": "^4.2.0", - "@walkeros/server-source-gcp": "^4.2.0", - "@walkeros/server-store-fs": "^4.2.0", - "@walkeros/server-store-gcs": "^4.2.0", - "@walkeros/server-store-s3": "^4.2.0", - "@walkeros/server-store-sheets": "^4.2.0", - "@walkeros/server-transformer-bot": "^4.2.0", - "@walkeros/server-transformer-file": "^4.2.0", - "@walkeros/server-transformer-fingerprint": "^4.2.0", - "@walkeros/transformer-ga4": "^4.2.0", - "@walkeros/walker.js": "^4.2.0", - "@walkeros/web-destination-amplitude": "^4.2.0", - "@walkeros/web-destination-api": "^4.2.0", - "@walkeros/web-destination-clarity": "^4.2.0", - "@walkeros/web-destination-d8a": "^4.2.0", - "@walkeros/web-destination-fullstory": "^4.2.0", - "@walkeros/web-destination-gtag": "^4.2.0", - "@walkeros/web-destination-heap": "^4.2.0", - "@walkeros/web-destination-hotjar": "^4.2.0", - "@walkeros/web-destination-linkedin": "^4.2.0", - "@walkeros/web-destination-matomo": "^4.2.0", - "@walkeros/web-destination-meta": "^4.2.0", - "@walkeros/web-destination-mixpanel": "^4.2.0", - "@walkeros/web-destination-optimizely": "^4.2.0", - "@walkeros/web-destination-pinterest": "^4.2.0", - "@walkeros/web-destination-piwikpro": "^4.2.0", - "@walkeros/web-destination-plausible": "^4.2.0", - "@walkeros/web-destination-posthog": "^4.2.0", - "@walkeros/web-destination-segment": "^4.2.0", - "@walkeros/web-destination-snowplow": "^4.2.0", - "@walkeros/web-destination-tiktok": "^4.2.0", - "@walkeros/web-source-browser": "^4.2.0", - "@walkeros/web-source-cmp-cookiefirst": "^4.2.0", - "@walkeros/web-source-cmp-cookiepro": "^4.2.0", - "@walkeros/web-source-cmp-usercentrics": "^4.2.0", - "@walkeros/web-source-datalayer": "^4.2.0", - "@walkeros/web-source-session": "^4.2.0", + "@walkeros/collector": "^4.2.1", + "@walkeros/core": "^4.2.1", + "@walkeros/explorer": "^4.2.1", + "@walkeros/server-destination-amplitude": "^4.2.1", + "@walkeros/server-destination-api": "^4.2.1", + "@walkeros/server-destination-aws": "^4.2.1", + "@walkeros/server-destination-bing": "^4.2.1", + "@walkeros/server-destination-criteo": "^4.2.1", + "@walkeros/server-destination-customerio": "^4.2.1", + "@walkeros/server-destination-datamanager": "^4.2.1", + "@walkeros/server-destination-file": "^4.2.1", + "@walkeros/server-destination-gcp": "^4.2.1", + "@walkeros/server-destination-hubspot": "^4.2.1", + "@walkeros/server-destination-kafka": "^4.2.1", + "@walkeros/server-destination-klaviyo": "^4.2.1", + "@walkeros/server-destination-linkedin": "^4.2.1", + "@walkeros/server-destination-meta": "^4.2.1", + "@walkeros/server-destination-mixpanel": "^4.2.1", + "@walkeros/server-destination-mparticle": "^4.2.1", + "@walkeros/server-destination-pinterest": "^4.2.1", + "@walkeros/server-destination-posthog": "^4.2.1", + "@walkeros/server-destination-reddit": "^4.2.1", + "@walkeros/server-destination-redis": "^4.2.1", + "@walkeros/server-destination-rudderstack": "^4.2.1", + "@walkeros/server-destination-segment": "^4.2.1", + "@walkeros/server-destination-slack": "^4.2.1", + "@walkeros/server-destination-snapchat": "^4.2.1", + "@walkeros/server-destination-sqlite": "^4.2.1", + "@walkeros/server-destination-tiktok": "^4.2.1", + "@walkeros/server-destination-twitter": "^4.2.1", + "@walkeros/server-source-aws": "^4.2.1", + "@walkeros/server-source-express": "^4.2.1", + "@walkeros/server-source-fetch": "^4.2.1", + "@walkeros/server-source-gcp": "^4.2.1", + "@walkeros/server-store-fs": "^4.2.1", + "@walkeros/server-store-gcs": "^4.2.1", + "@walkeros/server-store-s3": "^4.2.1", + "@walkeros/server-store-sheets": "^4.2.1", + "@walkeros/server-transformer-bot": "^4.2.1", + "@walkeros/server-transformer-file": "^4.2.1", + "@walkeros/server-transformer-fingerprint": "^4.2.1", + "@walkeros/transformer-ga4": "^4.2.1", + "@walkeros/walker.js": "^4.2.1", + "@walkeros/web-destination-amplitude": "^4.2.1", + "@walkeros/web-destination-api": "^4.2.1", + "@walkeros/web-destination-clarity": "^4.2.1", + "@walkeros/web-destination-d8a": "^4.2.1", + "@walkeros/web-destination-fullstory": "^4.2.1", + "@walkeros/web-destination-gtag": "^4.2.1", + "@walkeros/web-destination-heap": "^4.2.1", + "@walkeros/web-destination-hotjar": "^4.2.1", + "@walkeros/web-destination-linkedin": "^4.2.1", + "@walkeros/web-destination-matomo": "^4.2.1", + "@walkeros/web-destination-meta": "^4.2.1", + "@walkeros/web-destination-mixpanel": "^4.2.1", + "@walkeros/web-destination-optimizely": "^4.2.1", + "@walkeros/web-destination-pinterest": "^4.2.1", + "@walkeros/web-destination-piwikpro": "^4.2.1", + "@walkeros/web-destination-plausible": "^4.2.1", + "@walkeros/web-destination-posthog": "^4.2.1", + "@walkeros/web-destination-segment": "^4.2.1", + "@walkeros/web-destination-snowplow": "^4.2.1", + "@walkeros/web-destination-tiktok": "^4.2.1", + "@walkeros/web-source-browser": "^4.2.1", + "@walkeros/web-source-cmp-cookiefirst": "^4.2.1", + "@walkeros/web-source-cmp-cookiepro": "^4.2.1", + "@walkeros/web-source-cmp-usercentrics": "^4.2.1", + "@walkeros/web-source-datalayer": "^4.2.1", + "@walkeros/web-source-session": "^4.2.1", "css-loader": "^7.1.2", "prism-react-renderer": "^2.4.1", "react": "^19.2.4",