@sincpro/client-framework 0.1.0 → 0.2.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/AGENTS.md CHANGED
@@ -16,15 +16,16 @@ it says what this package is responsible for and what it is not. This file is ho
16
16
 
17
17
  ## Where things go
18
18
 
19
- | Folder | What |
20
- | -------------------- | --------------------------------------------------------------------------------------- |
21
- | `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract, ids |
22
- | `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
23
- | `src/services/` | `Feature`, `ApplicationService`, the runtime, the execution and what binds to it |
24
- | `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
25
- | `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
26
- | `src/testing/` | `memoryTracer`, `memoryReporter` — the ports in memory, for tests |
27
- | `tests/` | vitest in Node; `tests/support/` holds the fake domains and forge's real contracts |
19
+ | Folder | What |
20
+ | -------------------- | ------------------------------------------------------------------------------------------- |
21
+ | `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract, ids |
22
+ | `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
23
+ | `src/services/` | `Feature`, `ApplicationService`, the runtime, the execution and what binds to it |
24
+ | `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
25
+ | `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
26
+ | `src/adapters/` | Optional entries over a vendor (`./opentelemetry`, `./sentry`): the only place one is named |
27
+ | `src/testing/` | `memoryTracer`, `memoryReporter` — the ports in memory, for tests |
28
+ | `tests/` | vitest in Node; `tests/support/` holds the fake domains and forge's real contracts |
28
29
 
29
30
  ## Hard rules (the build enforces them — `make doctor`)
30
31
 
@@ -62,6 +63,12 @@ it says what this package is responsible for and what it is not. This file is ho
62
63
  and read the diff.
63
64
  - **A module name the generator writes is built in parts** (`codegen/names.ts`, `FRAMEWORK`): the
64
65
  build's `tsc-alias` rewrites this package's specifiers inside strings too.
66
+ - **An adapter wraps what the application built, never builds it.** `openTelemetryTracer` takes
67
+ the app's tracer, `sentryReporter` the app's client: no `register()`, no `Sentry.init()`, no
68
+ endpoint or DSN here, nothing read from the environment. A vendor's SDK is imported only in
69
+ `src/adapters/`, and nothing else imports an adapter (`make doctor`).
70
+ - **A span nothing records claims no trace.** OpenTelemetry's API with no provider answers an
71
+ all-zeros span: the adapter gives no ids, so no `traceparent` of zeros reaches the server.
65
72
  - **Reported means unexpected.** A `TransportError` the server answered with a 4xx is `refused`,
66
73
  an `AbortError` is `aborted`; neither is reported (`outcomeOf`). Change that with
67
74
  `RuntimeOptions.outcomeOf`, never by catching in a Feature.
package/MANIFESTO.md CHANGED
@@ -58,8 +58,11 @@ server-side render, in a script and in a test, unchanged.**
58
58
  with the libraries that do it best.
59
59
  - **Not offline storage.** SQLite on a phone or IndexedDB in a point of sale are platform
60
60
  adapters.
61
- - **Not a telemetry or error-tracking SDK.** It defines the ports and calls them; it bundles no
62
- vendor.
61
+ - **Not a telemetry or error-tracking SDK.** It defines the ports and calls them. For the
62
+ common vendors it offers optional adapters (`./opentelemetry`, `./sentry`) that wrap what the
63
+ application built — its provider, its client, with its endpoint and its DSN. It initialises
64
+ nothing and registers nothing global; an application that does not import them installs
65
+ nothing and ships nothing, and without a tracer or a reporter everything runs the same.
63
66
  - **Not where business rules live.** The server keeps them; a client reads what it publishes.
64
67
  - **Not an event bus**, and not a dispatcher: a use case is executed by calling it, and the
65
68
  interceptors are what make that call traced and reported.
package/README.md CHANGED
@@ -43,9 +43,21 @@ const project = bindServices(runtime, "project", {
43
43
  await project.closeIssue.execute(input); // project/closeIssue → project/preview, project/send
44
44
  ```
45
45
 
46
- `tracer` and `reporter` are the platform's: OpenTelemetry and GlitchTip in a browser, a queue on
47
- a phone, `memoryTracer()` and `memoryReporter()` (`@sincpro/client-framework/testing`) in a test.
48
- A use case that was never bound runs too, with nothing tracing.
46
+ `tracer` and `reporter` are injected by the application — this package initialises nothing.
47
+ Any object that fulfils the port works; for the common vendors there are optional adapters that
48
+ wrap what the application built:
49
+
50
+ ```ts
51
+ import { openTelemetryTracer } from "@sincpro/client-framework/opentelemetry"; // peer: @opentelemetry/api
52
+ import { sentryReporter } from "@sincpro/client-framework/sentry"; // no dependency
53
+
54
+ const tracer = openTelemetryTracer(provider.getTracer("my-app")); // the app's provider and exporter
55
+ const reporter = sentryReporter(client); // the app's Sentry-compatible client (GlitchTip too)
56
+ ```
57
+
58
+ Nothing is installed or shipped unless it is imported, and without a tracer or a reporter every use
59
+ case runs the same — nothing traces, nothing reports. In a test, `memoryTracer()` and
60
+ `memoryReporter()` (`@sincpro/client-framework/testing`).
49
61
 
50
62
  ## Where it sits
51
63
 
@@ -0,0 +1,21 @@
1
+ import { type Tracer as OtelTracer } from "@opentelemetry/api";
2
+ import type { Tracer } from "../observability/ports.js";
3
+ /**
4
+ * The `Tracer` port over OpenTelemetry's API: every use case a span of whatever provider the
5
+ * platform registered.
6
+ *
7
+ * // a browser (@sincpro/web-framework/telemetry does this), Node, React Native…
8
+ * const tracer = openTelemetryTracer(provider.getTracer("forge-web"));
9
+ * const runtime = createRuntime({ tracer });
10
+ *
11
+ * Context: only `@opentelemetry/api`, which runs the same on every platform — the provider, its
12
+ * exporter and where it sends are the platform's (the browser's OTLP over fetch, a phone's
13
+ * queue, a server's SDK). An optional entry with an optional peer: an application that does not
14
+ * import it installs nothing and ships nothing.
15
+ *
16
+ * The parent is given, never read from OpenTelemetry's context: a run carries its own execution
17
+ * (`Execution`), so two use cases at once never take each other's span, and no context manager
18
+ * (Zone.js) is needed. A span nothing records (a sampler said no, no provider registered)
19
+ * answers no ids — so no `traceparent` claims a trace that does not exist.
20
+ */
21
+ export declare function openTelemetryTracer(tracer: OtelTracer): Tracer;
@@ -0,0 +1,68 @@
1
+ import { isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, TraceFlags, } from "@opentelemetry/api";
2
+ /**
3
+ * The `Tracer` port over OpenTelemetry's API: every use case a span of whatever provider the
4
+ * platform registered.
5
+ *
6
+ * // a browser (@sincpro/web-framework/telemetry does this), Node, React Native…
7
+ * const tracer = openTelemetryTracer(provider.getTracer("forge-web"));
8
+ * const runtime = createRuntime({ tracer });
9
+ *
10
+ * Context: only `@opentelemetry/api`, which runs the same on every platform — the provider, its
11
+ * exporter and where it sends are the platform's (the browser's OTLP over fetch, a phone's
12
+ * queue, a server's SDK). An optional entry with an optional peer: an application that does not
13
+ * import it installs nothing and ships nothing.
14
+ *
15
+ * The parent is given, never read from OpenTelemetry's context: a run carries its own execution
16
+ * (`Execution`), so two use cases at once never take each other's span, and no context manager
17
+ * (Zone.js) is needed. A span nothing records (a sampler said no, no provider registered)
18
+ * answers no ids — so no `traceparent` claims a trace that does not exist.
19
+ */
20
+ export function openTelemetryTracer(tracer) {
21
+ return {
22
+ start(name, options) {
23
+ const parent = options.parent;
24
+ const parentContext = parent !== undefined && parent.traceId !== "" && parent.spanId !== ""
25
+ ? trace.setSpanContext(ROOT_CONTEXT, {
26
+ traceId: parent.traceId,
27
+ spanId: parent.spanId,
28
+ traceFlags: parent.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE,
29
+ isRemote: false,
30
+ })
31
+ : ROOT_CONTEXT;
32
+ const span = tracer.startSpan(name, {
33
+ kind: SpanKind.INTERNAL,
34
+ ...(options.attributes ? { attributes: options.attributes } : {}),
35
+ }, parentContext);
36
+ const own = span.spanContext();
37
+ const valid = isSpanContextValid(own);
38
+ const state = own.traceState?.serialize();
39
+ return {
40
+ traceId: valid ? own.traceId : "",
41
+ spanId: valid ? own.spanId : "",
42
+ sampled: valid && (own.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED,
43
+ ...(state ? { state } : {}),
44
+ set: (attributes) => void span.setAttributes(attributes),
45
+ end: (outcome, error) => close(span, outcome, error),
46
+ };
47
+ },
48
+ };
49
+ }
50
+ /**
51
+ * How the use case ended, said the way OpenTelemetry reads it: `failed` is an error with its
52
+ * exception; `refused` and `aborted` are expected answers — said in `sincpro.outcome`, never as
53
+ * an error, the way the backend leaves a refusal out of its error rate.
54
+ */
55
+ function close(span, outcome, error) {
56
+ span.setAttribute("sincpro.outcome", outcome);
57
+ if (outcome === "failed") {
58
+ if (error instanceof Error)
59
+ span.recordException(error);
60
+ span.setStatus({
61
+ code: SpanStatusCode.ERROR,
62
+ ...(error instanceof Error ? { message: error.message } : {}),
63
+ });
64
+ }
65
+ else if (outcome === "ok")
66
+ span.setStatus({ code: SpanStatusCode.OK });
67
+ span.end();
68
+ }
@@ -0,0 +1,28 @@
1
+ import type { ErrorReporter } from "../observability/ports.js";
2
+ /**
3
+ * What this adapter needs of a Sentry-compatible client: its `captureException`. A browser's
4
+ * `BrowserClient`, Node's, React Native's — and GlitchTip, which speaks Sentry's protocol.
5
+ */
6
+ export type ExceptionClient = {
7
+ captureException(error: unknown, hint?: {
8
+ captureContext?: {
9
+ tags?: Record<string, string>;
10
+ contexts?: Record<string, Record<string, unknown>>;
11
+ };
12
+ }): unknown;
13
+ };
14
+ /**
15
+ * The `ErrorReporter` port over a Sentry-compatible client: an unexpected error reported with the
16
+ * use case it happened in, its trace — the id the backend logged too — and its context.
17
+ *
18
+ * // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
19
+ * const reporter = sentryReporter(client);
20
+ * const runtime = createRuntime({ reporter });
21
+ *
22
+ * Context: it imports nothing — it takes the client the platform made (with its DSN, release
23
+ * and data rules), so it costs no dependency and works on every platform. What is reported is
24
+ * already decided by the runtime (`outcomeOf`: only `failed`); this only says where it happened.
25
+ * The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
26
+ * values are not the place for that.
27
+ */
28
+ export declare function sentryReporter(client: ExceptionClient): ErrorReporter;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The `ErrorReporter` port over a Sentry-compatible client: an unexpected error reported with the
3
+ * use case it happened in, its trace — the id the backend logged too — and its context.
4
+ *
5
+ * // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
6
+ * const reporter = sentryReporter(client);
7
+ * const runtime = createRuntime({ reporter });
8
+ *
9
+ * Context: it imports nothing — it takes the client the platform made (with its DSN, release
10
+ * and data rules), so it costs no dependency and works on every platform. What is reported is
11
+ * already decided by the runtime (`outcomeOf`: only `failed`); this only says where it happened.
12
+ * The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
13
+ * values are not the place for that.
14
+ */
15
+ export function sentryReporter(client) {
16
+ return {
17
+ report(error, about) {
18
+ client.captureException(error, {
19
+ captureContext: {
20
+ tags: {
21
+ "sincpro.use_case": about.useCase,
22
+ ...(about.traceId === "" ? {} : { trace_id: about.traceId }),
23
+ },
24
+ contexts: {
25
+ sincpro: {
26
+ use_case: about.useCase,
27
+ execution_id: about.executionId,
28
+ ...(about.traceId === "" ? {} : { trace_id: about.traceId }),
29
+ context: about.context,
30
+ },
31
+ },
32
+ },
33
+ });
34
+ },
35
+ };
36
+ }
@@ -114,6 +114,20 @@ function intercepted(target, binding) {
114
114
  TARGETS.set(proxy, target);
115
115
  return proxy;
116
116
  }
117
+ /**
118
+ * Whether this is the first run to report this error. A Feature that fails inside an
119
+ * ApplicationService throws through it: the error is reported once, by the run where it
120
+ * happened — its span is the one that says where — and every run around it still ends `failed`.
121
+ */
122
+ const REPORTED = new WeakSet();
123
+ function firstReport(error) {
124
+ if (error === null || (typeof error !== "object" && typeof error !== "function"))
125
+ return true;
126
+ if (REPORTED.has(error))
127
+ return false;
128
+ REPORTED.add(error);
129
+ return true;
130
+ }
117
131
  function quietly(work, otherwise) {
118
132
  try {
119
133
  return work();
@@ -177,7 +191,7 @@ function run(target, method, body, args, binding) {
177
191
  const finish = (failed, error) => {
178
192
  const outcome = failed ? quietly(() => runtime.outcomeOf(error), "failed") : "ok";
179
193
  quietly(() => span.end(outcome, error), undefined);
180
- if (outcome === "failed")
194
+ if (outcome === "failed" && firstReport(error))
181
195
  quietly(() => runtime.reporter?.report(error, {
182
196
  useCase,
183
197
  traceId: span.traceId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sincpro/client-framework",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The client side of sincpro_framework, for every platform: Feature and ApplicationService traced and reported by default, applications made of domains, and the typed client generated from a service's contract. No React, no DOM.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,6 +17,14 @@
17
17
  "./testing": {
18
18
  "types": "./dist/testing/index.d.ts",
19
19
  "default": "./dist/testing/index.js"
20
+ },
21
+ "./opentelemetry": {
22
+ "types": "./dist/adapters/opentelemetry.d.ts",
23
+ "default": "./dist/adapters/opentelemetry.js"
24
+ },
25
+ "./sentry": {
26
+ "types": "./dist/adapters/sentry.d.ts",
27
+ "default": "./dist/adapters/sentry.js"
20
28
  }
21
29
  },
22
30
  "bin": {
@@ -59,11 +67,19 @@
59
67
  "printWidth": 94
60
68
  },
61
69
  "peerDependencies": {
62
- "@sincpro/criteria": "^1.1.0"
70
+ "@opentelemetry/api": "^1.10.0",
71
+ "@sincpro/criteria": "^2.0.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@opentelemetry/api": {
75
+ "optional": true
76
+ }
63
77
  },
64
78
  "devDependencies": {
65
79
  "@eslint/js": "^10.0.1",
66
- "@sincpro/criteria": "^1.1.0",
80
+ "@opentelemetry/api": "1.10.0",
81
+ "@opentelemetry/sdk-trace": "3.1.0",
82
+ "@sincpro/criteria": "^2.0.0",
67
83
  "@types/node": "^26.6.6",
68
84
  "eslint": "^10.14.0",
69
85
  "eslint-config-prettier": "^10.1.8",