neosigma-sdk 0.0.1 → 0.1.0

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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025
3
+ Copyright (c) 2026 NeoSigma Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
-
package/README.md CHANGED
@@ -1,15 +1,53 @@
1
1
  # neosigma-sdk
2
2
 
3
- Placeholder package for the neosigma-sdk project.
3
+ NeoSigma TypeScript SDK. Emit product events that join your agent traces on
4
+ `run_id`, in NeoSigma's own telemetry store.
4
5
 
5
- This is a dummy package published to reserve the package name on npm. The actual project will be published here in the future.
6
+ ## Quick start
6
7
 
7
- ## Installation
8
+ ```ts
9
+ import { init, trace, capture, identify, installShutdownHandlers } from "neosigma-sdk";
8
10
 
9
- ```bash
10
- npm install neosigma-sdk
11
+ init(); // reads NEOSIGMA_API_KEY / NEOSIGMA_EVENTS_ENDPOINT; dark without a key
12
+ installShutdownHandlers(); // flush queued events on SIGTERM/SIGINT
13
+
14
+ await trace({ runId: run.id, distinctId: userId }, async () => {
15
+ identify(userId, { plan: "pro" });
16
+ capture("message sent", { length: text.length });
17
+ });
11
18
  ```
12
19
 
13
- ## Note
20
+ ## How it joins
21
+
22
+ `run_id` is the durable join key back to the agent's spans. Bind it once with
23
+ `trace({ runId })`; every `capture()` inside that scope carries it, so product
24
+ events sit next to the model's spans for the same run in ClickHouse. The ingest
25
+ resolves your API key to an `org_id` server-side (the client never sends it).
26
+
27
+ ## Configuration
28
+
29
+ | Env var | Default | Purpose |
30
+ | --- | --- | --- |
31
+ | `NEOSIGMA_API_KEY` | (none) | API key. Without it the SDK stays dark (a no-op). |
32
+ | `NEOSIGMA_EVENTS_ENDPOINT` | `https://otel.neosigma.ai/v1/events` | Where events are POSTed. |
33
+ | `NEOSIGMA_ENABLED` | `true` | Master switch. Set `false` to disable. |
34
+
35
+ Pass the same values to `init({ apiKey, eventsEndpoint, enabled })` to override
36
+ the environment.
37
+
38
+ ## Guarantees
39
+
40
+ - **Fail-open.** Telemetry never throws into your code; `capture()` is non-blocking.
41
+ Network errors are swallowed and counted; a dead key disables the sink rather than hammering the ingest.
42
+ - **Per-request identity.** Correlation ids live in `AsyncLocalStorage`, so
43
+ concurrent requests never leak ids into each other. Give each distinct actor
44
+ its own `trace()` scope.
45
+ - **Scalar properties.** Property values are coerced to strings, finite numbers,
46
+ or booleans at the boundary, so a stray `null` or nested object can never
47
+ reject a batch.
48
+
49
+ ## Correlation across processes
14
50
 
15
- This package is currently a placeholder. Please check back later for the actual project release.
51
+ `AsyncLocalStorage` does not cross a queue or process hop. When you hand work to
52
+ another service or a background job, pass `run.id` in the payload and re-bind it
53
+ on the far side with `trace({ runId })` (the Python SDK follows the same rule).
@@ -0,0 +1,19 @@
1
+ export declare const DEFAULT_EVENTS_ENDPOINT = "https://otel.neosigma.ai/v1/events";
2
+ export interface ResolvedConfig {
3
+ apiKey: string;
4
+ eventsEndpoint: string;
5
+ enabled: boolean;
6
+ }
7
+ type Env = Record<string, string | undefined>;
8
+ /**
9
+ * Resolve config from code overrides then NEOSIGMA_* env, with documented
10
+ * defaults. Mirrors the Python Settings (env_prefix NEOSIGMA_).
11
+ */
12
+ export declare function resolveConfig(overrides?: {
13
+ apiKey?: string;
14
+ eventsEndpoint?: string;
15
+ enabled?: boolean;
16
+ }, env?: Env): ResolvedConfig;
17
+ /** Dark-by-default: emit only when enabled AND an API key is present. */
18
+ export declare function isEnabled(c: ResolvedConfig): boolean;
19
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,18 @@
1
+ export const DEFAULT_EVENTS_ENDPOINT = "https://otel.neosigma.ai/v1/events";
2
+ /**
3
+ * Resolve config from code overrides then NEOSIGMA_* env, with documented
4
+ * defaults. Mirrors the Python Settings (env_prefix NEOSIGMA_).
5
+ */
6
+ export function resolveConfig(overrides = {}, env = process.env) {
7
+ return {
8
+ apiKey: overrides.apiKey ?? env.NEOSIGMA_API_KEY ?? "",
9
+ eventsEndpoint: overrides.eventsEndpoint ??
10
+ env.NEOSIGMA_EVENTS_ENDPOINT ??
11
+ DEFAULT_EVENTS_ENDPOINT,
12
+ enabled: overrides.enabled ?? env.NEOSIGMA_ENABLED !== "false",
13
+ };
14
+ }
15
+ /** Dark-by-default: emit only when enabled AND an API key is present. */
16
+ export function isEnabled(c) {
17
+ return c.enabled && c.apiKey !== "";
18
+ }
@@ -0,0 +1,34 @@
1
+ /** The ambient run-correlation ids: the join spine for events + spans. */
2
+ export interface Correlation {
3
+ runId: string;
4
+ distinctId: string;
5
+ sessionId: string;
6
+ }
7
+ export declare function currentRunId(): string;
8
+ export declare function currentDistinctId(): string;
9
+ export declare function currentSessionId(): string;
10
+ /**
11
+ * Run `fn` with the given correlation ids bound (only non-empty ones overlay the
12
+ * parent scope), returning `fn`'s value. AsyncLocalStorage propagates the store
13
+ * across awaits within this async context, but NOT across a process/queue hop:
14
+ * thread runId into the job payload and re-bind it on the far side, exactly as
15
+ * the Python SDK requires.
16
+ *
17
+ * Each distinct actor should own its own `trace()` frame. `setDistinctId` mutates
18
+ * the current frame's store, so fanning out concurrent sub-tasks that each
19
+ * identify a different user UNDER ONE shared frame would clobber (last writer
20
+ * wins); give each its own `trace({ distinctId })` frame instead.
21
+ */
22
+ export declare function trace<T>(opts: {
23
+ runId?: string;
24
+ distinctId?: string;
25
+ sessionId?: string;
26
+ }, fn: () => T): T;
27
+ /**
28
+ * Bind distinct_id for the rest of the current `trace()` scope (login -> later
29
+ * events), with no scoped reset, mirroring the Python set_distinct_id. Mutating
30
+ * the live store keeps it per-async-context (safe under concurrency when each
31
+ * actor owns its own frame). Outside any `trace()` scope there is no safe place
32
+ * to persist it in a shared server, so this no-ops with a rate-limited warning.
33
+ */
34
+ export declare function setDistinctId(distinctId: string): void;
@@ -0,0 +1,63 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { crossedWarnBoundary } from "./warn.js";
3
+ const als = new AsyncLocalStorage();
4
+ // Out-of-scope setDistinctId/identify drops: rate-limit the warning so loss is
5
+ // observable on a long-lived server (a one-shot warning would go silent for the
6
+ // life of the container after the first stray call).
7
+ let outsideScopeDrops = 0;
8
+ function store() {
9
+ return als.getStore();
10
+ }
11
+ export function currentRunId() {
12
+ return store()?.runId ?? "";
13
+ }
14
+ export function currentDistinctId() {
15
+ return store()?.distinctId ?? "";
16
+ }
17
+ export function currentSessionId() {
18
+ return store()?.sessionId ?? "";
19
+ }
20
+ /**
21
+ * Run `fn` with the given correlation ids bound (only non-empty ones overlay the
22
+ * parent scope), returning `fn`'s value. AsyncLocalStorage propagates the store
23
+ * across awaits within this async context, but NOT across a process/queue hop:
24
+ * thread runId into the job payload and re-bind it on the far side, exactly as
25
+ * the Python SDK requires.
26
+ *
27
+ * Each distinct actor should own its own `trace()` frame. `setDistinctId` mutates
28
+ * the current frame's store, so fanning out concurrent sub-tasks that each
29
+ * identify a different user UNDER ONE shared frame would clobber (last writer
30
+ * wins); give each its own `trace({ distinctId })` frame instead.
31
+ */
32
+ export function trace(opts, fn) {
33
+ const parent = store();
34
+ const merged = {
35
+ runId: opts.runId || parent?.runId || "",
36
+ distinctId: opts.distinctId || parent?.distinctId || "",
37
+ sessionId: opts.sessionId || parent?.sessionId || "",
38
+ };
39
+ return als.run(merged, fn);
40
+ }
41
+ /**
42
+ * Bind distinct_id for the rest of the current `trace()` scope (login -> later
43
+ * events), with no scoped reset, mirroring the Python set_distinct_id. Mutating
44
+ * the live store keeps it per-async-context (safe under concurrency when each
45
+ * actor owns its own frame). Outside any `trace()` scope there is no safe place
46
+ * to persist it in a shared server, so this no-ops with a rate-limited warning.
47
+ */
48
+ export function setDistinctId(distinctId) {
49
+ if (!distinctId)
50
+ return;
51
+ const current = store();
52
+ if (current) {
53
+ current.distinctId = distinctId;
54
+ return;
55
+ }
56
+ const prev = outsideScopeDrops;
57
+ outsideScopeDrops += 1;
58
+ if (crossedWarnBoundary(prev, 1)) {
59
+ console.warn(`[neosigma] setDistinctId/identify called outside a trace() scope ` +
60
+ `(${outsideScopeDrops} dropped); ignored. Wrap request handling in ` +
61
+ `trace({ runId }, ...) so identity stays per-request.`);
62
+ }
63
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The product-event wire contract: types that cross SDK -> sink -> ingest.
3
+ *
4
+ * These names ARE the cross-package contract. They mirror
5
+ * neosigma_sdk.events.models.ProductEvent and
6
+ * neosigma_otel_ingest.events_store.EventIngest field-for-field (snake_case).
7
+ * Renaming a field here silently breaks ingest, so treat the names as frozen
8
+ * and change them in lockstep with the Python contract. The guard is structural:
9
+ * contract/wire_fields.json is generated from the Python model and pinned by a
10
+ * Python sync test; test/parity.test.ts pins WIRE_FIELDS against that fixture.
11
+ *
12
+ * org_id is intentionally absent: it is authoritative server-side (resolved
13
+ * from the API key by the ingest), never client-supplied.
14
+ */
15
+ /** Scalar property values, matching the Python PropertyValue union. */
16
+ export type PropertyValue = string | number | boolean;
17
+ export interface ProductEvent {
18
+ /** Idempotency key, minted client-side (UUIDv4 string). */
19
+ event_uuid: string;
20
+ /** The product event name (e.g. "message sent"). */
21
+ event_name: string;
22
+ /** Analytics actor; "" when anonymous. */
23
+ distinct_id: string;
24
+ /** Durable join key back to agent spans; "" outside a correlated run. */
25
+ run_id: string;
26
+ /** Optional framework/session id. */
27
+ session_id: string;
28
+ /** Best-effort OTel trace id; "" when no active span. */
29
+ trace_id: string;
30
+ /** Event time in unix nanoseconds. bigint: ns exceeds Number safe range. */
31
+ timestamp_ns: bigint;
32
+ /** Scalar event properties (coerced to scalars at the capture boundary). */
33
+ properties: Record<string, PropertyValue>;
34
+ }
35
+ export interface EventBatch {
36
+ events: ProductEvent[];
37
+ }
38
+ /**
39
+ * The frozen wire field set. Pinned against the Python-derived
40
+ * contract/wire_fields.json by test/parity.test.ts.
41
+ */
42
+ export declare const WIRE_FIELDS: readonly string[];
43
+ /**
44
+ * Serialize a batch to the exact JSON the sink POSTs. This is the wire-encoding
45
+ * boundary, so it lives with the contract it encodes. The bigint timestamp_ns is
46
+ * emitted as a quoted decimal string ("1750..."); the ingest's Pydantic int
47
+ * field coerces it back to an exact integer (pinned by the Python regression
48
+ * test). JSON.stringify cannot encode a bigint, so the replacer stringifies it;
49
+ * every other value (including finite numbers) passes through unchanged, so a
50
+ * numeric property stays a bare JSON number and survives as an int.
51
+ */
52
+ export declare function serializeBatch(batch: EventBatch): string;
package/dist/events.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The product-event wire contract: types that cross SDK -> sink -> ingest.
3
+ *
4
+ * These names ARE the cross-package contract. They mirror
5
+ * neosigma_sdk.events.models.ProductEvent and
6
+ * neosigma_otel_ingest.events_store.EventIngest field-for-field (snake_case).
7
+ * Renaming a field here silently breaks ingest, so treat the names as frozen
8
+ * and change them in lockstep with the Python contract. The guard is structural:
9
+ * contract/wire_fields.json is generated from the Python model and pinned by a
10
+ * Python sync test; test/parity.test.ts pins WIRE_FIELDS against that fixture.
11
+ *
12
+ * org_id is intentionally absent: it is authoritative server-side (resolved
13
+ * from the API key by the ingest), never client-supplied.
14
+ */
15
+ /**
16
+ * The frozen wire field set. Pinned against the Python-derived
17
+ * contract/wire_fields.json by test/parity.test.ts.
18
+ */
19
+ export const WIRE_FIELDS = [
20
+ "event_uuid",
21
+ "event_name",
22
+ "distinct_id",
23
+ "run_id",
24
+ "session_id",
25
+ "trace_id",
26
+ "timestamp_ns",
27
+ "properties",
28
+ ];
29
+ /**
30
+ * Serialize a batch to the exact JSON the sink POSTs. This is the wire-encoding
31
+ * boundary, so it lives with the contract it encodes. The bigint timestamp_ns is
32
+ * emitted as a quoted decimal string ("1750..."); the ingest's Pydantic int
33
+ * field coerces it back to an exact integer (pinned by the Python regression
34
+ * test). JSON.stringify cannot encode a bigint, so the replacer stringifies it;
35
+ * every other value (including finite numbers) passes through unchanged, so a
36
+ * numeric property stays a bare JSON number and survives as an int.
37
+ */
38
+ export function serializeBatch(batch) {
39
+ return JSON.stringify(batch, (_key, value) => typeof value === "bigint" ? value.toString() : value);
40
+ }
@@ -0,0 +1,31 @@
1
+ export { trace } from "./context.js";
2
+ export { installShutdownHandlers } from "./sink.js";
3
+ export type { ProductEvent, PropertyValue } from "./events.js";
4
+ export type { EventSink } from "./sink.js";
5
+ /**
6
+ * Initialize the SDK. Idempotent. Installs the production HTTP sink when an API
7
+ * key is configured and enabled; otherwise stays dark (the default BufferSink,
8
+ * a no-op for the host). Never throws. To flush on shutdown, also call
9
+ * installShutdownHandlers() once at boot.
10
+ */
11
+ export declare function init(overrides?: {
12
+ apiKey?: string;
13
+ eventsEndpoint?: string;
14
+ enabled?: boolean;
15
+ }): void;
16
+ /**
17
+ * Emit a product event, stamped with the ambient run-correlation ids (bound by
18
+ * trace()). Mints an idempotency event_uuid and a call-site timestamp, and
19
+ * sanitizes properties to wire-safe scalars. Fail-open: never throws.
20
+ */
21
+ export declare function capture(eventName: string, properties?: Record<string, unknown>): void;
22
+ /**
23
+ * Bind distinct_id for subsequent events in the current trace() scope and emit a
24
+ * $identify event. Call once per request after auth, inside a trace() scope.
25
+ * Fail-open.
26
+ */
27
+ export declare function identify(distinctId: string, properties?: Record<string, unknown>): void;
28
+ /** Flush queued events (best-effort). Call before a serverless invocation ends. */
29
+ export declare function flush(): Promise<void>;
30
+ /** Stop the sink's flush loop, flush remaining events, and reset to dark. */
31
+ export declare function shutdown(): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isEnabled, resolveConfig } from "./config.js";
3
+ import { currentDistinctId, currentRunId, currentSessionId, setDistinctId, } from "./context.js";
4
+ import { coerceProperties } from "./properties.js";
5
+ import { HttpEventSink, getEventSink, resetSink, setEventSink } from "./sink.js";
6
+ import { nowNs } from "./time.js";
7
+ export { trace } from "./context.js";
8
+ export { installShutdownHandlers } from "./sink.js";
9
+ let initialized = false;
10
+ /**
11
+ * Initialize the SDK. Idempotent. Installs the production HTTP sink when an API
12
+ * key is configured and enabled; otherwise stays dark (the default BufferSink,
13
+ * a no-op for the host). Never throws. To flush on shutdown, also call
14
+ * installShutdownHandlers() once at boot.
15
+ */
16
+ export function init(overrides) {
17
+ if (initialized)
18
+ return;
19
+ initialized = true;
20
+ try {
21
+ const cfg = resolveConfig(overrides);
22
+ if (!isEnabled(cfg))
23
+ return; // dark-by-default
24
+ setEventSink(new HttpEventSink({ endpoint: cfg.eventsEndpoint, apiKey: cfg.apiKey }));
25
+ }
26
+ catch {
27
+ /* fail-open: degrade to dark, never break the host */
28
+ }
29
+ }
30
+ /**
31
+ * Emit a product event, stamped with the ambient run-correlation ids (bound by
32
+ * trace()). Mints an idempotency event_uuid and a call-site timestamp, and
33
+ * sanitizes properties to wire-safe scalars. Fail-open: never throws.
34
+ */
35
+ export function capture(eventName, properties) {
36
+ try {
37
+ const event = {
38
+ event_uuid: randomUUID(),
39
+ event_name: eventName,
40
+ distinct_id: currentDistinctId(),
41
+ run_id: currentRunId(),
42
+ session_id: currentSessionId(),
43
+ trace_id: "", // populated only if a TS OTel span integration is added later
44
+ timestamp_ns: nowNs(),
45
+ properties: coerceProperties(properties),
46
+ };
47
+ getEventSink().emit(event);
48
+ }
49
+ catch {
50
+ /* fail-open */
51
+ }
52
+ }
53
+ /**
54
+ * Bind distinct_id for subsequent events in the current trace() scope and emit a
55
+ * $identify event. Call once per request after auth, inside a trace() scope.
56
+ * Fail-open.
57
+ */
58
+ export function identify(distinctId, properties) {
59
+ try {
60
+ setDistinctId(distinctId);
61
+ capture("$identify", properties);
62
+ }
63
+ catch {
64
+ /* fail-open */
65
+ }
66
+ }
67
+ /** Flush queued events (best-effort). Call before a serverless invocation ends. */
68
+ export async function flush() {
69
+ try {
70
+ await getEventSink().flush?.();
71
+ }
72
+ catch {
73
+ /* fail-open */
74
+ }
75
+ }
76
+ /** Stop the sink's flush loop, flush remaining events, and reset to dark. */
77
+ export async function shutdown() {
78
+ await resetSink();
79
+ initialized = false;
80
+ }
@@ -0,0 +1,17 @@
1
+ import type { PropertyValue } from "./events.js";
2
+ /**
3
+ * Coerce arbitrary caller-supplied property values into wire-safe scalars.
4
+ *
5
+ * `PropertyValue` is a compile-time type only; JS does not enforce it at
6
+ * runtime, so a caller can pass `null`, `NaN`, a nested object, etc. Sending any
7
+ * of those would make the ingest's Pydantic model reject the ENTIRE batch (the
8
+ * sink is fail-open and never inspects status, so the whole batch is silently
9
+ * lost). This boundary guarantees every emitted value is a `string`, a finite
10
+ * `number`, or a `boolean`, which the ingest always accepts.
11
+ *
12
+ * Rules: finite scalars pass through; `undefined`/`null`/functions/symbols are
13
+ * dropped (no meaningful scalar); non-finite numbers and bigints are stringified;
14
+ * `Date`s become a bare ISO-8601 string; other objects/arrays are JSON-stringified
15
+ * (best-effort, dropped if not serializable).
16
+ */
17
+ export declare function coerceProperties(props: Record<string, unknown> | undefined): Record<string, PropertyValue>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Coerce arbitrary caller-supplied property values into wire-safe scalars.
3
+ *
4
+ * `PropertyValue` is a compile-time type only; JS does not enforce it at
5
+ * runtime, so a caller can pass `null`, `NaN`, a nested object, etc. Sending any
6
+ * of those would make the ingest's Pydantic model reject the ENTIRE batch (the
7
+ * sink is fail-open and never inspects status, so the whole batch is silently
8
+ * lost). This boundary guarantees every emitted value is a `string`, a finite
9
+ * `number`, or a `boolean`, which the ingest always accepts.
10
+ *
11
+ * Rules: finite scalars pass through; `undefined`/`null`/functions/symbols are
12
+ * dropped (no meaningful scalar); non-finite numbers and bigints are stringified;
13
+ * `Date`s become a bare ISO-8601 string; other objects/arrays are JSON-stringified
14
+ * (best-effort, dropped if not serializable).
15
+ */
16
+ export function coerceProperties(props) {
17
+ const out = {};
18
+ if (!props)
19
+ return out;
20
+ for (const [key, value] of Object.entries(props)) {
21
+ const coerced = coerceValue(value);
22
+ if (coerced !== undefined)
23
+ out[key] = coerced;
24
+ }
25
+ return out;
26
+ }
27
+ function coerceValue(value) {
28
+ switch (typeof value) {
29
+ case "string":
30
+ case "boolean":
31
+ return value;
32
+ case "number":
33
+ return Number.isFinite(value) ? value : String(value);
34
+ case "bigint":
35
+ return value.toString();
36
+ case "undefined":
37
+ case "symbol":
38
+ case "function":
39
+ return undefined;
40
+ case "object":
41
+ if (value === null)
42
+ return undefined;
43
+ // A Date via JSON.stringify would arrive as a double-quoted ISO string
44
+ // ('"2026-..."'); emit the bare ISO-8601 string instead.
45
+ if (value instanceof Date) {
46
+ return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
47
+ }
48
+ try {
49
+ return JSON.stringify(value);
50
+ }
51
+ catch {
52
+ return undefined; // circular / non-serializable: drop rather than throw
53
+ }
54
+ default:
55
+ return undefined;
56
+ }
57
+ }
package/dist/sink.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { type ProductEvent } from "./events.js";
2
+ /** Where capture() hands a built event. flush/shutdown are optional. */
3
+ export interface EventSink {
4
+ emit(event: ProductEvent): void;
5
+ flush?(): Promise<void>;
6
+ shutdown?(): Promise<void>;
7
+ }
8
+ /** Default in-process sink: a bounded buffer for tests / dark mode. */
9
+ export declare class BufferSink implements EventSink {
10
+ private readonly maxlen;
11
+ private events;
12
+ constructor(maxlen?: number);
13
+ emit(event: ProductEvent): void;
14
+ drain(): ProductEvent[];
15
+ }
16
+ /** Bounded, background-flushing sink that POSTs event batches to the ingest. */
17
+ export declare class HttpEventSink implements EventSink {
18
+ private readonly endpoint;
19
+ private readonly apiKey;
20
+ private readonly maxQueue;
21
+ private readonly batchSize;
22
+ private readonly timeoutMs;
23
+ private readonly schemeOk;
24
+ private queue;
25
+ private timer;
26
+ private activeFlush;
27
+ /** Total events dropped (queue-full, non-http endpoint, disabled, or post error). */
28
+ dropped: number;
29
+ /** Set when a 401/403 marks the key dead; the sink then stops POSTing. */
30
+ disabled: boolean;
31
+ constructor(opts: {
32
+ endpoint: string;
33
+ apiKey: string;
34
+ maxQueue?: number;
35
+ batchSize?: number;
36
+ flushIntervalMs?: number;
37
+ timeoutMs?: number;
38
+ });
39
+ emit(event: ProductEvent): void;
40
+ flush(): Promise<void>;
41
+ shutdown(): Promise<void>;
42
+ private stopTimer;
43
+ private recordDropped;
44
+ private post;
45
+ }
46
+ export declare function setEventSink(sink: EventSink): void;
47
+ export declare function getEventSink(): EventSink;
48
+ export declare function flushSink(): Promise<void>;
49
+ export declare function resetSink(): Promise<void>;
50
+ /**
51
+ * Opt-in: flush queued events on SIGTERM/SIGINT (Cloud Run sends SIGTERM before
52
+ * stopping an instance, e.g. on every deploy/scale-in). Without this, the
53
+ * trailing queue is lost on teardown. Idempotent. The host calls this once at
54
+ * boot; left opt-in so a library import never installs process handlers behind
55
+ * the host's back.
56
+ *
57
+ * Registered with `once`, and after the flush the signal is re-raised to the
58
+ * (now listener-less) process so Node's default handler terminates it. Without
59
+ * the re-raise, installing a handler suppresses the default exit and the process
60
+ * would hang if any other handle (e.g. an HTTP server) is still open.
61
+ */
62
+ export declare function installShutdownHandlers(proc?: NodeJS.Process): void;
package/dist/sink.js ADDED
@@ -0,0 +1,238 @@
1
+ import { serializeBatch } from "./events.js";
2
+ import { crossedWarnBoundary } from "./warn.js";
3
+ const API_KEY_HEADER = "X-Neosigma-API-Key";
4
+ const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
5
+ // Sink tunables. Genuine constants, not env-config: a client SDK's queue/batch
6
+ // shape is a property of the SDK, not something a host tunes per deploy. Named
7
+ // (not inline literals) so the values are discoverable in one place; the two
8
+ // caps are distinct concerns that merely share a number today.
9
+ const DEFAULT_MAX_QUEUE = 10_000; // HttpEventSink backpressure cap (events)
10
+ const DEFAULT_BUFFER_MAXLEN = 10_000; // BufferSink ring cap (events)
11
+ const DEFAULT_BATCH_SIZE = 100; // events per POST
12
+ const DEFAULT_TIMEOUT_MS = 10_000; // per-POST abort deadline
13
+ const DEFAULT_FLUSH_INTERVAL_MS = 5_000; // background flush cadence
14
+ // Safety cap on shutdown()'s drain loop: keeps teardown bounded if a host keeps
15
+ // emit()ing *during* shutdown. Realistic teardown needs 1-2 rounds; this is slack.
16
+ const MAX_SHUTDOWN_FLUSHES = 100;
17
+ /** Default in-process sink: a bounded buffer for tests / dark mode. */
18
+ export class BufferSink {
19
+ maxlen;
20
+ events = [];
21
+ constructor(maxlen = DEFAULT_BUFFER_MAXLEN) {
22
+ this.maxlen = maxlen;
23
+ }
24
+ emit(event) {
25
+ this.events.push(event);
26
+ if (this.events.length > this.maxlen)
27
+ this.events.shift();
28
+ }
29
+ drain() {
30
+ const out = this.events;
31
+ this.events = [];
32
+ return out;
33
+ }
34
+ }
35
+ /** Bounded, background-flushing sink that POSTs event batches to the ingest. */
36
+ export class HttpEventSink {
37
+ endpoint;
38
+ apiKey;
39
+ maxQueue;
40
+ batchSize;
41
+ timeoutMs;
42
+ schemeOk;
43
+ queue = [];
44
+ timer;
45
+ // The in-flight flush, or null. Concurrent flush() callers await this same
46
+ // promise instead of no-op'ing, so shutdown() genuinely waits for an in-flight
47
+ // background flush to finish before returning (otherwise it could drop the
48
+ // events still being POSTed — the exact thing shutdown exists to prevent).
49
+ activeFlush = null;
50
+ /** Total events dropped (queue-full, non-http endpoint, disabled, or post error). */
51
+ dropped = 0;
52
+ /** Set when a 401/403 marks the key dead; the sink then stops POSTing. */
53
+ disabled = false;
54
+ constructor(opts) {
55
+ this.endpoint = opts.endpoint;
56
+ this.apiKey = opts.apiKey;
57
+ this.maxQueue = opts.maxQueue ?? DEFAULT_MAX_QUEUE;
58
+ this.batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
59
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
60
+ let ok = false;
61
+ try {
62
+ ok = ALLOWED_SCHEMES.has(new URL(this.endpoint).protocol);
63
+ }
64
+ catch {
65
+ ok = false;
66
+ }
67
+ this.schemeOk = ok;
68
+ const interval = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
69
+ // .catch keeps the SDK fail-open: flush() swallows post errors today, but a
70
+ // background timer must never turn a future/unexpected rejection into an
71
+ // unhandled rejection that could crash the host.
72
+ this.timer = setInterval(() => void this.flush().catch(() => { }), interval);
73
+ // Never hold the process open for telemetry.
74
+ this.timer.unref?.();
75
+ }
76
+ emit(event) {
77
+ if (this.disabled || this.queue.length >= this.maxQueue) {
78
+ this.recordDropped(1);
79
+ return;
80
+ }
81
+ this.queue.push(event);
82
+ }
83
+ flush() {
84
+ // A concurrent flush already owns the drain: await the same operation rather
85
+ // than starting a second one (or returning before it completes).
86
+ if (this.activeFlush)
87
+ return this.activeFlush;
88
+ this.activeFlush = (async () => {
89
+ try {
90
+ // Snapshot at entry so flush() is bounded by the queue size NOW and always
91
+ // terminates, even if emit() keeps adding during the awaits (the Python
92
+ // sink drains a snapshot for the same reason).
93
+ const pending = this.queue;
94
+ this.queue = [];
95
+ for (let i = 0; i < pending.length; i += this.batchSize) {
96
+ await this.post(pending.slice(i, i + this.batchSize));
97
+ }
98
+ }
99
+ finally {
100
+ this.activeFlush = null;
101
+ }
102
+ })();
103
+ return this.activeFlush;
104
+ }
105
+ async shutdown() {
106
+ this.stopTimer();
107
+ // Await any in-flight background flush, then keep flushing until the queue is
108
+ // empty, so events emit()ed while a flush was in flight aren't left behind.
109
+ // Bounded by MAX_SHUTDOWN_FLUSHES: a host that keeps emit()ing *during*
110
+ // shutdown can't spin teardown forever — past the cap the few trailing events
111
+ // are dropped rather than hanging the process.
112
+ await this.flush();
113
+ for (let round = 0; this.queue.length > 0 && round < MAX_SHUTDOWN_FLUSHES; round++) {
114
+ await this.flush();
115
+ }
116
+ // Hit the cap with the queue still non-empty: a host that out-paced the drain.
117
+ // Account for the trailing events as dropped — counted and subject to the warn
118
+ // cadence, never silently lost — and clear the queue so the sink ends empty,
119
+ // as this method's contract (and the comment above) promises.
120
+ if (this.queue.length > 0) {
121
+ this.recordDropped(this.queue.length);
122
+ this.queue = [];
123
+ }
124
+ }
125
+ stopTimer() {
126
+ if (this.timer) {
127
+ clearInterval(this.timer);
128
+ this.timer = undefined;
129
+ }
130
+ }
131
+ recordDropped(n) {
132
+ const prev = this.dropped;
133
+ this.dropped += n;
134
+ if (crossedWarnBoundary(prev, n)) {
135
+ // First loss, then rate-limited: silent total loss must be observable. The
136
+ // api key is never included in the message.
137
+ console.warn(`[neosigma] dropped ${this.dropped} event(s) (ingest unreachable, ` +
138
+ `disabled, or queue full).`);
139
+ }
140
+ }
141
+ async post(events) {
142
+ if (events.length === 0)
143
+ return;
144
+ if (this.disabled || !this.schemeOk) {
145
+ // Disabled (dead key) or a non-http(s) endpoint (misconfig): never POST.
146
+ this.recordDropped(events.length);
147
+ return;
148
+ }
149
+ const body = { events };
150
+ const controller = new AbortController();
151
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
152
+ try {
153
+ const res = await fetch(this.endpoint, {
154
+ method: "POST",
155
+ headers: {
156
+ "Content-Type": "application/json",
157
+ [API_KEY_HEADER]: this.apiKey,
158
+ },
159
+ body: serializeBatch(body),
160
+ signal: controller.signal,
161
+ });
162
+ if (res.status === 401 || res.status === 403) {
163
+ // Dead/revoked key: stop hammering the ingest for the rest of the process.
164
+ this.disabled = true;
165
+ this.stopTimer();
166
+ this.recordDropped(events.length);
167
+ }
168
+ else if (!res.ok) {
169
+ this.recordDropped(events.length);
170
+ }
171
+ }
172
+ catch {
173
+ // Fail-open: the ingest being unreachable must not break the host.
174
+ this.recordDropped(events.length);
175
+ }
176
+ finally {
177
+ clearTimeout(timer);
178
+ }
179
+ }
180
+ }
181
+ // ── active-sink registry ─────────────────────────────────────────────────────
182
+ let active = new BufferSink();
183
+ export function setEventSink(sink) {
184
+ // Shut the old sink down so its flush timer isn't orphaned, but best-effort:
185
+ // the SDK is fail-open, so a rejecting shutdown must not surface as an
186
+ // unhandled rejection.
187
+ if (active !== sink)
188
+ void active.shutdown?.().catch(() => { });
189
+ active = sink;
190
+ }
191
+ export function getEventSink() {
192
+ return active;
193
+ }
194
+ export async function flushSink() {
195
+ try {
196
+ await active.flush?.();
197
+ }
198
+ catch {
199
+ /* fail-open */
200
+ }
201
+ }
202
+ export async function resetSink() {
203
+ try {
204
+ await active.shutdown?.();
205
+ }
206
+ catch {
207
+ /* fail-open */
208
+ }
209
+ active = new BufferSink();
210
+ }
211
+ // ── graceful shutdown ────────────────────────────────────────────────────────
212
+ let handlersInstalled = false;
213
+ /**
214
+ * Opt-in: flush queued events on SIGTERM/SIGINT (Cloud Run sends SIGTERM before
215
+ * stopping an instance, e.g. on every deploy/scale-in). Without this, the
216
+ * trailing queue is lost on teardown. Idempotent. The host calls this once at
217
+ * boot; left opt-in so a library import never installs process handlers behind
218
+ * the host's back.
219
+ *
220
+ * Registered with `once`, and after the flush the signal is re-raised to the
221
+ * (now listener-less) process so Node's default handler terminates it. Without
222
+ * the re-raise, installing a handler suppresses the default exit and the process
223
+ * would hang if any other handle (e.g. an HTTP server) is still open.
224
+ */
225
+ export function installShutdownHandlers(proc = process) {
226
+ if (handlersInstalled)
227
+ return;
228
+ handlersInstalled = true;
229
+ const onSignal = (signal) => {
230
+ void resetSink()
231
+ .catch(() => { })
232
+ .then(() => {
233
+ proc.kill(proc.pid, signal);
234
+ });
235
+ };
236
+ proc.once("SIGTERM", () => onSignal("SIGTERM"));
237
+ proc.once("SIGINT", () => onSignal("SIGINT"));
238
+ }
package/dist/time.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Current wall-clock time in unix nanoseconds.
3
+ *
4
+ * Date.now() is millisecond-resolution and a safe integer; multiplying by 1e6 in
5
+ * bigint yields exact nanoseconds. A JS number could not hold ns exactly (it
6
+ * exceeds 2**53), hence bigint. The DateTime64(6) store keeps the value exactly.
7
+ */
8
+ export declare function nowNs(): bigint;
package/dist/time.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Current wall-clock time in unix nanoseconds.
3
+ *
4
+ * Date.now() is millisecond-resolution and a safe integer; multiplying by 1e6 in
5
+ * bigint yields exact nanoseconds. A JS number could not hold ns exactly (it
6
+ * exceeds 2**53), hence bigint. The DateTime64(6) store keeps the value exactly.
7
+ */
8
+ export function nowNs() {
9
+ return BigInt(Date.now()) * 1000000n;
10
+ }
package/dist/warn.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Rate-limited drop/no-op warnings. Two paths lose data silently by design
3
+ * (the fail-open sink and out-of-scope setDistinctId); a one-shot warning would
4
+ * go quiet for the life of a long-running container after the first stray call,
5
+ * and warning every time would flood the log. This is the shared cadence: warn
6
+ * on the first loss, then once per WARN_EVERY thereafter.
7
+ */
8
+ export declare const WARN_EVERY = 100;
9
+ /**
10
+ * True if adding `n` losses to a running total of `prev` crosses a warn
11
+ * boundary: the very first loss (prev === 0), or a step over a WARN_EVERY
12
+ * multiple. Pure so both callers (the sink's batched drops and context's
13
+ * single-step drops) share one tested rule instead of hand-rolling the modulo.
14
+ */
15
+ export declare function crossedWarnBoundary(prev: number, n: number, every?: number): boolean;
package/dist/warn.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Rate-limited drop/no-op warnings. Two paths lose data silently by design
3
+ * (the fail-open sink and out-of-scope setDistinctId); a one-shot warning would
4
+ * go quiet for the life of a long-running container after the first stray call,
5
+ * and warning every time would flood the log. This is the shared cadence: warn
6
+ * on the first loss, then once per WARN_EVERY thereafter.
7
+ */
8
+ export const WARN_EVERY = 100;
9
+ /**
10
+ * True if adding `n` losses to a running total of `prev` crosses a warn
11
+ * boundary: the very first loss (prev === 0), or a step over a WARN_EVERY
12
+ * multiple. Pure so both callers (the sink's batched drops and context's
13
+ * single-step drops) share one tested rule instead of hand-rolling the modulo.
14
+ */
15
+ export function crossedWarnBoundary(prev, n, every = WARN_EVERY) {
16
+ if (prev === 0)
17
+ return true;
18
+ return Math.floor(prev / every) !== Math.floor((prev + n) / every);
19
+ }
package/package.json CHANGED
@@ -1,23 +1,56 @@
1
1
  {
2
- "name": "neosigma-sdk",
3
- "version": "0.0.1",
4
- "description": "Placeholder package for the neosigma-sdk project",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "keywords": [
10
- "neosigma",
11
- "neosigma-sdk"
12
- ],
13
- "author": "NeoSigma Inc. <founders@neosigma.ai>",
14
- "license": "MIT",
15
- "homepage": "https://www.neosigma.ai",
16
- "repository": {
17
- "type": "git",
18
- "url": ""
19
- },
20
- "engines": {
21
- "node": ">=14.0.0"
2
+ "name": "neosigma-sdk",
3
+ "version": "0.1.0",
4
+ "description": "NeoSigma TypeScript SDK: emit product events that join your agent traces on run_id.",
5
+ "license": "MIT",
6
+ "homepage": "https://www.neosigma.ai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/neosigmaai/neosigma.git",
10
+ "directory": "packages/neosigma-sdk-ts"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/neosigmaai/neosigma/issues"
14
+ },
15
+ "keywords": [
16
+ "neosigma",
17
+ "neosigma-sdk",
18
+ "telemetry",
19
+ "observability",
20
+ "analytics"
21
+ ],
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
22
29
  }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "provenance": true
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.json",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "typecheck": "tsc -p tsconfig.json --noEmit",
48
+ "smoke:bun": "bun run scripts/bun-smoke.ts",
49
+ "prepack": "npm run build"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^20.0.0",
53
+ "typescript": "^5.7.0",
54
+ "vitest": "^2.1.8"
55
+ }
23
56
  }
package/index.js DELETED
@@ -1,13 +0,0 @@
1
- /**
2
- * neosigma-sdk - Placeholder package
3
- *
4
- * This is a placeholder package for the neosigma-sdk project.
5
- * The actual project will be published here in the future.
6
- */
7
-
8
- module.exports = {
9
- version: '0.0.1',
10
- neosigma: function () {
11
- // NeoSigma SDK function
12
- }
13
- };