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 +1 -2
- package/README.md +45 -7
- package/dist/config.d.ts +19 -0
- package/dist/config.js +18 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +63 -0
- package/dist/events.d.ts +52 -0
- package/dist/events.js +40 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +80 -0
- package/dist/properties.d.ts +17 -0
- package/dist/properties.js +57 -0
- package/dist/sink.d.ts +62 -0
- package/dist/sink.js +238 -0
- package/dist/time.d.ts +8 -0
- package/dist/time.js +10 -0
- package/dist/warn.d.ts +15 -0
- package/dist/warn.js +19 -0
- package/package.json +53 -20
- package/index.js +0 -13
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
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
|
-
|
|
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
|
-
|
|
6
|
+
## Quick start
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
```ts
|
|
9
|
+
import { init, trace, capture, identify, installShutdownHandlers } from "neosigma-sdk";
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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).
|
package/dist/config.d.ts
ADDED
|
@@ -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;
|
package/dist/context.js
ADDED
|
@@ -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
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
|
|
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
|
-
};
|