@sincpro/mobile 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +234 -2
  2. package/dist/entrypoints/db/migrations.d.ts +3 -1
  3. package/dist/entrypoints/db/migrations.js +65 -0
  4. package/dist/entrypoints/queue/QueueProcessor.js +1 -25
  5. package/dist/entrypoints/queue/subscriber_filter.d.ts +9 -0
  6. package/dist/entrypoints/queue/subscriber_filter.js +25 -0
  7. package/dist/framework/base_module.d.ts +1 -0
  8. package/dist/framework/base_module.js +6 -1
  9. package/dist/framework/createApp.d.ts +6 -0
  10. package/dist/framework/createApp.js +5 -1
  11. package/dist/index.d.ts +6 -0
  12. package/dist/index.js +3 -0
  13. package/dist/infrastructure/context_manager/context.d.ts +16 -0
  14. package/dist/infrastructure/context_manager/context.js +37 -0
  15. package/dist/infrastructure/context_manager/context_api.d.ts +28 -0
  16. package/dist/infrastructure/context_manager/context_api.js +55 -0
  17. package/dist/infrastructure/context_manager/context_key.d.ts +15 -0
  18. package/dist/infrastructure/context_manager/context_key.js +3 -0
  19. package/dist/infrastructure/context_manager/context_manager_facade.d.ts +107 -0
  20. package/dist/infrastructure/context_manager/context_manager_facade.js +113 -0
  21. package/dist/infrastructure/context_manager/decorators/propagate_context.d.ts +24 -0
  22. package/dist/infrastructure/context_manager/decorators/propagate_context.js +52 -0
  23. package/dist/infrastructure/context_manager/decorators/with_context.d.ts +34 -0
  24. package/dist/infrastructure/context_manager/decorators/with_context.js +44 -0
  25. package/dist/infrastructure/context_manager/index.d.ts +11 -0
  26. package/dist/infrastructure/context_manager/index.js +12 -0
  27. package/dist/infrastructure/context_manager/managed_context.d.ts +87 -0
  28. package/dist/infrastructure/context_manager/managed_context.js +144 -0
  29. package/dist/infrastructure/context_manager/stack_context_manager.d.ts +39 -0
  30. package/dist/infrastructure/context_manager/stack_context_manager.js +56 -0
  31. package/dist/infrastructure/database/connector.js +10 -1
  32. package/dist/infrastructure/interceptor/index.d.ts +4 -0
  33. package/dist/infrastructure/interceptor/index.js +2 -0
  34. package/dist/infrastructure/interceptor/intercept.d.ts +57 -0
  35. package/dist/infrastructure/interceptor/intercept.js +112 -0
  36. package/dist/infrastructure/interceptor/logging_hooks.d.ts +22 -0
  37. package/dist/infrastructure/interceptor/logging_hooks.js +27 -0
  38. package/dist/infrastructure/logger.d.ts +11 -16
  39. package/dist/infrastructure/logger.js +61 -18
  40. package/dist/infrastructure/telemetry/buffer_registry.d.ts +15 -0
  41. package/dist/infrastructure/telemetry/buffer_registry.js +26 -0
  42. package/dist/infrastructure/telemetry/connectivity_state.d.ts +20 -0
  43. package/dist/infrastructure/telemetry/connectivity_state.js +28 -0
  44. package/dist/infrastructure/telemetry/context_keys.d.ts +7 -0
  45. package/dist/infrastructure/telemetry/context_keys.js +7 -0
  46. package/dist/infrastructure/telemetry/fetch_with_timeout.d.ts +8 -0
  47. package/dist/infrastructure/telemetry/fetch_with_timeout.js +17 -0
  48. package/dist/infrastructure/telemetry/flush_telemetry.d.ts +40 -0
  49. package/dist/infrastructure/telemetry/flush_telemetry.js +72 -0
  50. package/dist/infrastructure/telemetry/index.d.ts +17 -0
  51. package/dist/infrastructure/telemetry/index.js +17 -0
  52. package/dist/infrastructure/telemetry/init_telemetry.d.ts +21 -0
  53. package/dist/infrastructure/telemetry/init_telemetry.js +85 -0
  54. package/dist/infrastructure/telemetry/logging/flush_job.d.ts +44 -0
  55. package/dist/infrastructure/telemetry/logging/flush_job.js +69 -0
  56. package/dist/infrastructure/telemetry/logging/log_queue_repository.d.ts +76 -0
  57. package/dist/infrastructure/telemetry/logging/log_queue_repository.js +147 -0
  58. package/dist/infrastructure/telemetry/logging/loki_client.d.ts +32 -0
  59. package/dist/infrastructure/telemetry/logging/loki_client.js +118 -0
  60. package/dist/infrastructure/telemetry/logging/loki_registry.d.ts +12 -0
  61. package/dist/infrastructure/telemetry/logging/loki_registry.js +17 -0
  62. package/dist/infrastructure/telemetry/telemetry_flush_worker.d.ts +35 -0
  63. package/dist/infrastructure/telemetry/telemetry_flush_worker.js +65 -0
  64. package/dist/infrastructure/telemetry/telemetry_signal.d.ts +18 -0
  65. package/dist/infrastructure/telemetry/telemetry_signal.js +33 -0
  66. package/dist/infrastructure/telemetry/tracing/active_span.d.ts +22 -0
  67. package/dist/infrastructure/telemetry/tracing/active_span.js +40 -0
  68. package/dist/infrastructure/telemetry/tracing/otlp_client.d.ts +13 -0
  69. package/dist/infrastructure/telemetry/tracing/otlp_client.js +34 -0
  70. package/dist/infrastructure/telemetry/tracing/otlp_serializer.d.ts +67 -0
  71. package/dist/infrastructure/telemetry/tracing/otlp_serializer.js +101 -0
  72. package/dist/infrastructure/telemetry/tracing/span_flush_job.d.ts +25 -0
  73. package/dist/infrastructure/telemetry/tracing/span_flush_job.js +42 -0
  74. package/dist/infrastructure/telemetry/tracing/span_queue_repository.d.ts +82 -0
  75. package/dist/infrastructure/telemetry/tracing/span_queue_repository.js +134 -0
  76. package/dist/infrastructure/telemetry/tracing/span_sampler.d.ts +40 -0
  77. package/dist/infrastructure/telemetry/tracing/span_sampler.js +75 -0
  78. package/dist/infrastructure/telemetry/tracing/span_serializer.d.ts +48 -0
  79. package/dist/infrastructure/telemetry/tracing/span_serializer.js +36 -0
  80. package/dist/infrastructure/telemetry/tracing/sqlite_span_exporter.d.ts +26 -0
  81. package/dist/infrastructure/telemetry/tracing/sqlite_span_exporter.js +47 -0
  82. package/dist/infrastructure/telemetry/tracing/tracer.d.ts +22 -0
  83. package/dist/infrastructure/telemetry/tracing/tracer.js +45 -0
  84. package/dist/infrastructure/telemetry/tracing/tracing_hooks.d.ts +10 -0
  85. package/dist/infrastructure/telemetry/tracing/tracing_hooks.js +59 -0
  86. package/dist/infrastructure/telemetry/tracing_facade.d.ts +50 -0
  87. package/dist/infrastructure/telemetry/tracing_facade.js +139 -0
  88. package/dist/infrastructure/telemetry/types.d.ts +120 -0
  89. package/dist/infrastructure/telemetry/types.js +1 -0
  90. package/dist/ui/components/molecules/TimeZoneSelectorModal.js +7 -1
  91. package/package.json +17 -11
@@ -0,0 +1,118 @@
1
+ import { DEFAULT_SEND_TIMEOUT_MS, fetchWithTimeout } from "../fetch_with_timeout";
2
+ // ---------------------------------------------------------------------------
3
+ // Monotonic nanosecond timestamp
4
+ // Loki rejects duplicate timestamps within the same stream (HTTP 400).
5
+ // If two push() calls happen in the same millisecond they'd produce identical
6
+ // ns values — the counter guarantees strict monotonicity.
7
+ // ---------------------------------------------------------------------------
8
+ let _lastMs = 0;
9
+ let _nsSeq = 0;
10
+ /** @internal — exposed for compliance testing only */
11
+ export function _nowNs() {
12
+ const ms = Date.now();
13
+ if (ms === _lastMs) {
14
+ _nsSeq += 1;
15
+ }
16
+ else {
17
+ _lastMs = ms;
18
+ _nsSeq = 0;
19
+ }
20
+ // BigInt required: ms * 1_000_000 ≈ 1.78e18 in 2026, which exceeds Number.MAX_SAFE_INTEGER
21
+ // (~9e15). Float64 granularity at that magnitude is ~512, so the _nsSeq offset would be
22
+ // invisible — producing duplicate timestamps that Loki rejects with HTTP 400.
23
+ return String(BigInt(ms) * 1000000n + BigInt(_nsSeq));
24
+ }
25
+ /**
26
+ * HTTP client for Loki's push API (`POST /loki/api/v1/push`).
27
+ *
28
+ * `push()` is fire-and-forget: on failure it enqueues to the outbox (if provided) so the
29
+ * flush cron can retry. `deliver()` is the awaitable batch-send used by the flush cron.
30
+ */
31
+ export class LokiClient {
32
+ config;
33
+ queue;
34
+ timeoutMs;
35
+ constructor(config, queue, timeoutMs = DEFAULT_SEND_TIMEOUT_MS) {
36
+ this.config = config;
37
+ this.queue = queue ?? null;
38
+ this.timeoutMs = timeoutMs;
39
+ }
40
+ push(level, message) {
41
+ const body = JSON.stringify({
42
+ streams: [
43
+ {
44
+ stream: { ...this.config.labels, level },
45
+ values: [[_nowNs(), message]],
46
+ },
47
+ ],
48
+ });
49
+ fetchWithTimeout(`${this.config.endpoint}/loki/api/v1/push`, {
50
+ method: "POST",
51
+ headers: this.requestHeaders(),
52
+ body,
53
+ }, this.timeoutMs).catch(() => {
54
+ const q = this.queue;
55
+ if (q) {
56
+ queueMicrotask(() => q.enqueue(level, message).catch(() => { }));
57
+ }
58
+ });
59
+ }
60
+ /**
61
+ * Batch-delivers outbox entries to Loki. Groups entries by level into separate streams.
62
+ * Throws on network error or non-2xx response — caller must not remove entries from the
63
+ * outbox unless this resolves successfully.
64
+ */
65
+ async deliver(entries) {
66
+ if (entries.length === 0)
67
+ return;
68
+ const byLevel = new Map();
69
+ for (const entry of entries) {
70
+ if (!byLevel.has(entry.level))
71
+ byLevel.set(entry.level, []);
72
+ byLevel.get(entry.level).push(entry);
73
+ }
74
+ const streams = Array.from(byLevel.entries()).map(([level, levelEntries]) => ({
75
+ stream: { ...this.config.labels, level },
76
+ values: levelEntries.map((entry, i) => {
77
+ const baseNs = BigInt(new Date(entry.created_at.replace(" ", "T") + "Z").getTime()) * 1000000n;
78
+ return [String(baseNs + BigInt(i)), entry.message];
79
+ }),
80
+ }));
81
+ const res = await fetchWithTimeout(`${this.config.endpoint}/loki/api/v1/push`, {
82
+ method: "POST",
83
+ headers: this.requestHeaders(),
84
+ body: JSON.stringify({ streams }),
85
+ }, this.timeoutMs);
86
+ if (!res.ok) {
87
+ throw new Error(`Loki batch delivery failed: HTTP ${res.status}`);
88
+ }
89
+ }
90
+ /**
91
+ * Builds the headers for every request: the `auth` shortcut, then any custom
92
+ * `headers` (which win on conflict — e.g. a gateway API key, or an explicit
93
+ * Authorization overriding `auth`), then a non-negotiable `Content-Type`.
94
+ * The body is always JSON, so a custom `content-type` (any casing) must not
95
+ * override it — Loki would reject the push.
96
+ */
97
+ requestHeaders() {
98
+ const custom = { ...this.config.headers };
99
+ for (const key of Object.keys(custom)) {
100
+ if (key.toLowerCase() === "content-type")
101
+ delete custom[key];
102
+ }
103
+ return {
104
+ ...this.authHeader(),
105
+ ...custom,
106
+ "Content-Type": "application/json",
107
+ };
108
+ }
109
+ authHeader() {
110
+ const auth = this.config.auth;
111
+ if (!auth)
112
+ return {};
113
+ if (auth.type === "basic") {
114
+ return { Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}` };
115
+ }
116
+ return { Authorization: `Bearer ${auth.token}` };
117
+ }
118
+ }
@@ -0,0 +1,12 @@
1
+ import type { LokiConfig } from "../types";
2
+ import type { LogQueueRepository } from "./log_queue_repository";
3
+ import { LokiClient } from "./loki_client";
4
+ /**
5
+ * Low-level client initializer. Prefer `initTelemetry` from the telemetry index, which also
6
+ * wires the SQLite outbox and the flush cron. Use this directly only in tests.
7
+ */
8
+ export declare function initLokiClient(config: LokiConfig, queue?: LogQueueRepository, timeoutMs?: number): void;
9
+ /** Returns the active LokiClient, or null if telemetry was not initialized. */
10
+ export declare function getLokiClient(): LokiClient | null;
11
+ /** @internal — test use only */
12
+ export declare function _resetLokiClient(): void;
@@ -0,0 +1,17 @@
1
+ import { LokiClient } from "./loki_client";
2
+ let _client = null;
3
+ /**
4
+ * Low-level client initializer. Prefer `initTelemetry` from the telemetry index, which also
5
+ * wires the SQLite outbox and the flush cron. Use this directly only in tests.
6
+ */
7
+ export function initLokiClient(config, queue, timeoutMs) {
8
+ _client = new LokiClient(config, queue, timeoutMs);
9
+ }
10
+ /** Returns the active LokiClient, or null if telemetry was not initialized. */
11
+ export function getLokiClient() {
12
+ return _client;
13
+ }
14
+ /** @internal — test use only */
15
+ export function _resetLokiClient() {
16
+ _client = null;
17
+ }
@@ -0,0 +1,35 @@
1
+ import type { ConnectivityState } from "./connectivity_state";
2
+ import type { FlushTelemetry } from "./flush_telemetry";
3
+ import type { TelemetrySignal } from "./telemetry_signal";
4
+ /** Default background-flush interval (minutes). ≥15 → real OS background task. */
5
+ export declare const DEFAULT_BACKGROUND_INTERVAL_MIN = 15;
6
+ export interface FlushWorkerOptions {
7
+ /** Background cron interval in minutes (≥15 → OS background task). 0 disables it. */
8
+ backgroundIntervalMin?: number;
9
+ /** Subscribe to InternetIsUp/Down events when the host app emits them. */
10
+ onConnectivityEvents?: boolean;
11
+ }
12
+ /**
13
+ * Orchestrates telemetry delivery from three independent triggers, all funneling
14
+ * into the same {@link FlushTelemetry} use case — none of them required:
15
+ *
16
+ * 1. Production signal (intrinsic, always on): producing telemetry schedules a
17
+ * debounced flush. This is the primary path and needs no cron or events.
18
+ * 2. Connectivity events (opt-in): if the host app emits InternetIsUp/Down,
19
+ * reconnecting triggers an immediate backlog drain.
20
+ * 3. Background cron (opt-in): a long-interval OS background task probes and
21
+ * drains, as a safety net for whatever the other two missed.
22
+ *
23
+ * The framework never *depends* on the connectivity cron or the event bus.
24
+ */
25
+ export declare class TelemetryFlushWorker {
26
+ private readonly flush;
27
+ private readonly signal;
28
+ private readonly connectivity;
29
+ private readonly opts;
30
+ private cron;
31
+ private unsubscribe;
32
+ constructor(flush: FlushTelemetry, signal: TelemetrySignal, connectivity: ConnectivityState, opts?: FlushWorkerOptions);
33
+ start(): void;
34
+ stop(): Promise<void>;
35
+ }
@@ -0,0 +1,65 @@
1
+ import { InternetIsDownEvent, InternetIsUpEvent, } from "../../domain/connectivity/events";
2
+ import { UIEventBus } from "../../infrastructure/ui/UIEventBus";
3
+ import { CronWorker } from "../../infrastructure/workers/CronWorker";
4
+ /** Default background-flush interval (minutes). ≥15 → real OS background task. */
5
+ export const DEFAULT_BACKGROUND_INTERVAL_MIN = 15;
6
+ /**
7
+ * Orchestrates telemetry delivery from three independent triggers, all funneling
8
+ * into the same {@link FlushTelemetry} use case — none of them required:
9
+ *
10
+ * 1. Production signal (intrinsic, always on): producing telemetry schedules a
11
+ * debounced flush. This is the primary path and needs no cron or events.
12
+ * 2. Connectivity events (opt-in): if the host app emits InternetIsUp/Down,
13
+ * reconnecting triggers an immediate backlog drain.
14
+ * 3. Background cron (opt-in): a long-interval OS background task probes and
15
+ * drains, as a safety net for whatever the other two missed.
16
+ *
17
+ * The framework never *depends* on the connectivity cron or the event bus.
18
+ */
19
+ export class TelemetryFlushWorker {
20
+ flush;
21
+ signal;
22
+ connectivity;
23
+ opts;
24
+ cron = null;
25
+ unsubscribe = null;
26
+ constructor(flush, signal, connectivity, opts = {}) {
27
+ this.flush = flush;
28
+ this.signal = signal;
29
+ this.connectivity = connectivity;
30
+ this.opts = opts;
31
+ }
32
+ start() {
33
+ // Trigger 2 — reconnect events (opt-in). Up → drain now (reset backoff);
34
+ // Down → mark offline (informational; delivery still self-recovers via backoff).
35
+ if (this.opts.onConnectivityEvents) {
36
+ const offUp = UIEventBus.on(InternetIsUpEvent.name, () => {
37
+ this.connectivity.markOnline();
38
+ this.flush.run({ resetBackoff: true }).catch(() => { });
39
+ });
40
+ const offDown = UIEventBus.on(InternetIsDownEvent.name, () => {
41
+ this.connectivity.markOffline();
42
+ });
43
+ this.unsubscribe = () => {
44
+ offUp();
45
+ offDown();
46
+ };
47
+ }
48
+ // Trigger 3 — background safety net (opt-in). Resets backoff so a long
49
+ // offline backlog drains promptly when the OS runs the task.
50
+ const intervalMin = this.opts.backgroundIntervalMin ?? DEFAULT_BACKGROUND_INTERVAL_MIN;
51
+ if (intervalMin > 0) {
52
+ this.cron = new CronWorker("TELEMETRY_FLUSH", () => this.flush.run({ resetBackoff: true }), intervalMin);
53
+ this.cron.start().catch(() => { });
54
+ }
55
+ }
56
+ async stop() {
57
+ this.signal.dispose();
58
+ this.unsubscribe?.();
59
+ this.unsubscribe = null;
60
+ if (this.cron) {
61
+ await this.cron.unregister().catch(() => { });
62
+ this.cron = null;
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,18 @@
1
+ /** Default coalescing window for production-triggered flushes. */
2
+ export declare const DEFAULT_SIGNAL_DEBOUNCE_MS = 3000;
3
+ /**
4
+ * In-memory, debounced trigger. Telemetry is produced far faster than it should
5
+ * be shipped, so a burst of `notify()` calls collapses into a single `onFlush`
6
+ * after a quiet window. This is the production trigger — intrinsic to producing
7
+ * telemetry, with no dependency on crons or the event bus.
8
+ */
9
+ export declare class TelemetrySignal {
10
+ private readonly onFlush;
11
+ private readonly debounceMs;
12
+ private timer;
13
+ constructor(onFlush: () => void, debounceMs?: number);
14
+ /** Schedules a single flush; further calls within the window are coalesced. */
15
+ notify(): void;
16
+ /** Cancels a pending flush (used on teardown / re-init). */
17
+ dispose(): void;
18
+ }
@@ -0,0 +1,33 @@
1
+ /** Default coalescing window for production-triggered flushes. */
2
+ export const DEFAULT_SIGNAL_DEBOUNCE_MS = 3000;
3
+ /**
4
+ * In-memory, debounced trigger. Telemetry is produced far faster than it should
5
+ * be shipped, so a burst of `notify()` calls collapses into a single `onFlush`
6
+ * after a quiet window. This is the production trigger — intrinsic to producing
7
+ * telemetry, with no dependency on crons or the event bus.
8
+ */
9
+ export class TelemetrySignal {
10
+ onFlush;
11
+ debounceMs;
12
+ timer = null;
13
+ constructor(onFlush, debounceMs = DEFAULT_SIGNAL_DEBOUNCE_MS) {
14
+ this.onFlush = onFlush;
15
+ this.debounceMs = debounceMs;
16
+ }
17
+ /** Schedules a single flush; further calls within the window are coalesced. */
18
+ notify() {
19
+ if (this.timer)
20
+ return; // a flush is already scheduled — coalesce
21
+ this.timer = setTimeout(() => {
22
+ this.timer = null;
23
+ this.onFlush();
24
+ }, this.debounceMs);
25
+ }
26
+ /** Cancels a pending flush (used on teardown / re-init). */
27
+ dispose() {
28
+ if (this.timer) {
29
+ clearTimeout(this.timer);
30
+ this.timer = null;
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,22 @@
1
+ import type { SpanContext } from "@opentelemetry/api";
2
+ /**
3
+ * Returns the SpanContext (traceId + spanId + traceFlags) of the span
4
+ * currently active in the framework context, or null if no span is active.
5
+ *
6
+ * Use this to correlate logs with traces — inject traceId/spanId into log
7
+ * lines so Grafana can link from Loki to Tempo automatically.
8
+ *
9
+ * @example
10
+ * const span = getActiveSpanContext();
11
+ * logger.info(`payment processed trace_id=${span?.traceId ?? "-"}`);
12
+ */
13
+ export declare function getActiveSpanContext(): SpanContext | null;
14
+ /**
15
+ * Formats the active trace context as a log suffix ready to append to any
16
+ * log line. Returns an empty string when no span is active so callers don't
17
+ * need to branch.
18
+ *
19
+ * Output: " trace_id=<id> span_id=<id>"
20
+ * Grafana Derived Fields can extract these and link to Tempo automatically.
21
+ */
22
+ export declare function activeTraceLabel(): string;
@@ -0,0 +1,40 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ import { _getContextManager } from "../../context_manager/index";
3
+ import { OTEL_CTX_KEY } from "../context_keys";
4
+ /**
5
+ * Returns the SpanContext (traceId + spanId + traceFlags) of the span
6
+ * currently active in the framework context, or null if no span is active.
7
+ *
8
+ * Use this to correlate logs with traces — inject traceId/spanId into log
9
+ * lines so Grafana can link from Loki to Tempo automatically.
10
+ *
11
+ * @example
12
+ * const span = getActiveSpanContext();
13
+ * logger.info(`payment processed trace_id=${span?.traceId ?? "-"}`);
14
+ */
15
+ export function getActiveSpanContext() {
16
+ const otelCtx = _getContextManager().active().get(OTEL_CTX_KEY);
17
+ if (!otelCtx)
18
+ return null;
19
+ const span = trace.getSpan(otelCtx);
20
+ if (!span)
21
+ return null;
22
+ const ctx = span.spanContext();
23
+ // A NonRecordingSpan has a valid-looking spanContext but isRemote/sampled may
24
+ // differ. We only return it if the span is actually recording (sampled).
25
+ return span.isRecording() ? ctx : null;
26
+ }
27
+ /**
28
+ * Formats the active trace context as a log suffix ready to append to any
29
+ * log line. Returns an empty string when no span is active so callers don't
30
+ * need to branch.
31
+ *
32
+ * Output: " trace_id=<id> span_id=<id>"
33
+ * Grafana Derived Fields can extract these and link to Tempo automatically.
34
+ */
35
+ export function activeTraceLabel() {
36
+ const ctx = getActiveSpanContext();
37
+ if (!ctx)
38
+ return "";
39
+ return ` trace_id=${ctx.traceId} span_id=${ctx.spanId}`;
40
+ }
@@ -0,0 +1,13 @@
1
+ import type { OtlpConfig } from "../types";
2
+ import type { SpanRow } from "./span_queue_repository";
3
+ /**
4
+ * Sends a batch of spans to an OTLP/HTTP collector (Grafana Alloy, Jaeger, etc.).
5
+ * Throws on non-2xx so the caller (span_flush_job) can apply backoff.
6
+ */
7
+ export declare class OtlpClient {
8
+ private readonly config;
9
+ private readonly timeoutMs;
10
+ constructor(config: OtlpConfig, timeoutMs?: number);
11
+ deliver(spans: SpanRow[]): Promise<void>;
12
+ private requestHeaders;
13
+ }
@@ -0,0 +1,34 @@
1
+ import { DEFAULT_SEND_TIMEOUT_MS, fetchWithTimeout } from "../fetch_with_timeout";
2
+ import { serializeToOtlp } from "./otlp_serializer";
3
+ /**
4
+ * Sends a batch of spans to an OTLP/HTTP collector (Grafana Alloy, Jaeger, etc.).
5
+ * Throws on non-2xx so the caller (span_flush_job) can apply backoff.
6
+ */
7
+ export class OtlpClient {
8
+ config;
9
+ timeoutMs;
10
+ constructor(config, timeoutMs = DEFAULT_SEND_TIMEOUT_MS) {
11
+ this.config = config;
12
+ this.timeoutMs = timeoutMs;
13
+ }
14
+ async deliver(spans) {
15
+ if (spans.length === 0)
16
+ return;
17
+ const body = JSON.stringify(serializeToOtlp(spans));
18
+ const url = `${this.config.endpoint.replace(/\/$/, "")}/v1/traces`;
19
+ const res = await fetchWithTimeout(url, {
20
+ method: "POST",
21
+ headers: this.requestHeaders(),
22
+ body,
23
+ }, this.timeoutMs);
24
+ if (!res.ok) {
25
+ throw new Error(`OTLP collector responded ${res.status}`);
26
+ }
27
+ }
28
+ requestHeaders() {
29
+ const custom = this.config.headers ?? {};
30
+ // Strip any content-type the caller may have set — JSON is always required for OTLP/HTTP.
31
+ const stripped = Object.fromEntries(Object.entries(custom).filter(([k]) => k.toLowerCase() !== "content-type"));
32
+ return { ...stripped, "Content-Type": "application/json" };
33
+ }
34
+ }
@@ -0,0 +1,67 @@
1
+ import type { SpanRow } from "./span_queue_repository";
2
+ type OtlpAnyValue = {
3
+ stringValue: string;
4
+ } | {
5
+ intValue: string;
6
+ } | {
7
+ doubleValue: number;
8
+ } | {
9
+ boolValue: boolean;
10
+ };
11
+ interface OtlpKeyValue {
12
+ key: string;
13
+ value: OtlpAnyValue;
14
+ }
15
+ interface OtlpEvent {
16
+ name: string;
17
+ timeUnixNano: string;
18
+ attributes: OtlpKeyValue[];
19
+ }
20
+ interface OtlpLink {
21
+ traceId: string;
22
+ spanId: string;
23
+ attributes: OtlpKeyValue[];
24
+ }
25
+ interface OtlpSpan {
26
+ traceId: string;
27
+ spanId: string;
28
+ parentSpanId?: string;
29
+ name: string;
30
+ kind: number;
31
+ startTimeUnixNano: string;
32
+ endTimeUnixNano: string;
33
+ attributes: OtlpKeyValue[];
34
+ events: OtlpEvent[];
35
+ links: OtlpLink[];
36
+ status: {
37
+ code: number;
38
+ message?: string;
39
+ };
40
+ }
41
+ interface OtlpScopeSpans {
42
+ scope: {
43
+ name: string;
44
+ };
45
+ spans: OtlpSpan[];
46
+ }
47
+ interface OtlpResourceSpans {
48
+ resource: {
49
+ attributes: OtlpKeyValue[];
50
+ };
51
+ scopeSpans: OtlpScopeSpans[];
52
+ }
53
+ export interface OtlpExportRequest {
54
+ resourceSpans: OtlpResourceSpans[];
55
+ }
56
+ /**
57
+ * Converts a batch of SpanRows (from SQLite) into an OTLP/HTTP JSON payload.
58
+ *
59
+ * Grouping strategy: spans sharing the same `resource_attrs` JSON are placed
60
+ * under the same ResourceSpans entry. All spans are placed under a single
61
+ * scope named "@sincpro/mobile".
62
+ *
63
+ * traceId and spanId are hex strings — OTLP/JSON uses hex (not base64).
64
+ * Timestamps are nanosecond strings — already stored that way in SQLite.
65
+ */
66
+ export declare function serializeToOtlp(spans: SpanRow[]): OtlpExportRequest;
67
+ export {};
@@ -0,0 +1,101 @@
1
+ function encodeValue(v) {
2
+ if (typeof v === "string")
3
+ return { stringValue: v };
4
+ // intValue is a string in OTLP/JSON (uint64 can exceed JS Number precision)
5
+ if (typeof v === "number" && Number.isInteger(v))
6
+ return { intValue: String(v) };
7
+ if (typeof v === "number")
8
+ return { doubleValue: v };
9
+ if (typeof v === "boolean")
10
+ return { boolValue: v };
11
+ return { stringValue: String(v) };
12
+ }
13
+ function encodeAttrs(json) {
14
+ try {
15
+ const obj = JSON.parse(json);
16
+ return Object.entries(obj).map(([key, value]) => ({ key, value: encodeValue(value) }));
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ }
22
+ function parseEvents(json) {
23
+ try {
24
+ const raw = JSON.parse(json);
25
+ return raw.map((e) => ({
26
+ name: e.name,
27
+ timeUnixNano: e.timeUnixNano,
28
+ attributes: Object.entries(e.attributes ?? {}).map(([key, value]) => ({
29
+ key,
30
+ value: encodeValue(value),
31
+ })),
32
+ }));
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ function parseLinks(json) {
39
+ try {
40
+ const raw = JSON.parse(json);
41
+ return raw.map((l) => ({
42
+ traceId: l.traceId,
43
+ spanId: l.spanId,
44
+ attributes: Object.entries(l.attributes ?? {}).map(([key, value]) => ({
45
+ key,
46
+ value: encodeValue(value),
47
+ })),
48
+ }));
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Serializer
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Converts a batch of SpanRows (from SQLite) into an OTLP/HTTP JSON payload.
59
+ *
60
+ * Grouping strategy: spans sharing the same `resource_attrs` JSON are placed
61
+ * under the same ResourceSpans entry. All spans are placed under a single
62
+ * scope named "@sincpro/mobile".
63
+ *
64
+ * traceId and spanId are hex strings — OTLP/JSON uses hex (not base64).
65
+ * Timestamps are nanosecond strings — already stored that way in SQLite.
66
+ */
67
+ export function serializeToOtlp(spans) {
68
+ // Group by resource_attrs to minimize payload size
69
+ const byResource = new Map();
70
+ for (const span of spans) {
71
+ const key = span.resource_attrs;
72
+ const group = byResource.get(key) ?? [];
73
+ group.push(span);
74
+ byResource.set(key, group);
75
+ }
76
+ const resourceSpans = [];
77
+ for (const [resourceJson, group] of byResource) {
78
+ const otlpSpans = group.map((s) => {
79
+ const span = {
80
+ traceId: s.trace_id,
81
+ spanId: s.span_id,
82
+ name: s.name,
83
+ kind: s.kind,
84
+ startTimeUnixNano: s.start_time_unixnano,
85
+ endTimeUnixNano: s.end_time_unixnano,
86
+ attributes: encodeAttrs(s.attributes),
87
+ events: parseEvents(s.events ?? "[]"),
88
+ links: parseLinks(s.links ?? "[]"),
89
+ status: { code: s.status_code, message: s.status_message || undefined },
90
+ };
91
+ if (s.parent_span_id)
92
+ span.parentSpanId = s.parent_span_id;
93
+ return span;
94
+ });
95
+ resourceSpans.push({
96
+ resource: { attributes: encodeAttrs(resourceJson) },
97
+ scopeSpans: [{ scope: { name: "@sincpro/mobile" }, spans: otlpSpans }],
98
+ });
99
+ }
100
+ return { resourceSpans };
101
+ }
@@ -0,0 +1,25 @@
1
+ import type { FlushResult } from "../logging/flush_job";
2
+ import type { SpanRow } from "./span_queue_repository";
3
+ export declare const SPAN_FLUSH_BATCH_SIZE = 100;
4
+ export declare const SPAN_MAX_BATCHES_PER_TICK = 5;
5
+ export interface SpanFlushJobState {
6
+ consecutiveFailures: number;
7
+ skipTicksRemaining: number;
8
+ }
9
+ export declare function createSpanFlushJobState(): SpanFlushJobState;
10
+ export interface SpanFlushClient {
11
+ deliver(spans: SpanRow[]): Promise<void>;
12
+ }
13
+ export interface SpanFlushQueue {
14
+ pruneExpired(): Promise<void>;
15
+ findPending(limit: number): Promise<SpanRow[]>;
16
+ removeMany(ids: number[]): Promise<void>;
17
+ }
18
+ /**
19
+ * One tick of the span flush cron.
20
+ *
21
+ * Mirrors runLogFlushJob (logs) — same at-least-once guarantee:
22
+ * deliver() always precedes removeMany(). A span delivered twice to the
23
+ * collector is preferable to a span never delivered.
24
+ */
25
+ export declare function runSpanFlushJob(client: SpanFlushClient, queue: SpanFlushQueue, state: SpanFlushJobState): Promise<FlushResult>;
@@ -0,0 +1,42 @@
1
+ export const SPAN_FLUSH_BATCH_SIZE = 100;
2
+ export const SPAN_MAX_BATCHES_PER_TICK = 5;
3
+ const MAX_SKIP_TICKS = 32;
4
+ export function createSpanFlushJobState() {
5
+ return { consecutiveFailures: 0, skipTicksRemaining: 0 };
6
+ }
7
+ /**
8
+ * One tick of the span flush cron.
9
+ *
10
+ * Mirrors runLogFlushJob (logs) — same at-least-once guarantee:
11
+ * deliver() always precedes removeMany(). A span delivered twice to the
12
+ * collector is preferable to a span never delivered.
13
+ */
14
+ export async function runSpanFlushJob(client, queue, state) {
15
+ if (state.skipTicksRemaining > 0) {
16
+ state.skipTicksRemaining -= 1;
17
+ return { delivered: 0, failed: false };
18
+ }
19
+ await queue.pruneExpired();
20
+ let batchesDelivered = 0;
21
+ while (batchesDelivered < SPAN_MAX_BATCHES_PER_TICK) {
22
+ const spans = await queue.findPending(SPAN_FLUSH_BATCH_SIZE);
23
+ if (spans.length === 0)
24
+ break;
25
+ try {
26
+ await client.deliver(spans);
27
+ }
28
+ catch {
29
+ state.consecutiveFailures += 1;
30
+ state.skipTicksRemaining = Math.min(Math.pow(2, state.consecutiveFailures - 1), MAX_SKIP_TICKS);
31
+ return { delivered: batchesDelivered, failed: true };
32
+ }
33
+ await queue.removeMany(spans.map((s) => s.id));
34
+ batchesDelivered += 1;
35
+ if (spans.length < SPAN_FLUSH_BATCH_SIZE)
36
+ break;
37
+ }
38
+ if (batchesDelivered > 0) {
39
+ state.consecutiveFailures = 0;
40
+ }
41
+ return { delivered: batchesDelivered, failed: false };
42
+ }