@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 +16 -9
- package/MANIFESTO.md +5 -2
- package/README.md +15 -3
- package/dist/adapters/opentelemetry.d.ts +21 -0
- package/dist/adapters/opentelemetry.js +68 -0
- package/dist/adapters/sentry.d.ts +28 -0
- package/dist/adapters/sentry.js +36 -0
- package/dist/services/feature.js +15 -1
- package/package.json +19 -3
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/
|
|
27
|
-
| `
|
|
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
|
|
62
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|
package/dist/services/feature.js
CHANGED
|
@@ -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.
|
|
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
|
-
"@
|
|
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
|
-
"@
|
|
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",
|