@finesoft/front 0.1.75 → 0.1.77
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/README.md +2 -411
- package/dist/browser.d.mts +2 -0
- package/dist/browser.mjs +1 -0
- package/dist/index.d.mts +2 -1248
- package/dist/index.mjs +54 -3557
- package/dist/server-data-DGbiKzMS.d.mts +1249 -0
- package/dist/start-app-BdXBCcor.mjs +2 -0
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +12 -3
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# 7. DI container
|
|
2
|
+
|
|
3
|
+
The framework uses a small dependency-injection container with parent/child scopes. It exists for two reasons:
|
|
4
|
+
|
|
5
|
+
1. **Request isolation on the server** — every SSR request gets its own scope, so per-request state (auth, request id) doesn't leak across requests.
|
|
6
|
+
2. **Decoupled testing** — controllers resolve their dependencies from the container, so tests can swap any of them.
|
|
7
|
+
|
|
8
|
+
The container is intentionally small. There are no decorators, no annotations, no auto-wiring. You register factories, you resolve by key.
|
|
9
|
+
|
|
10
|
+
## Registering
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Container } from "@finesoft/front";
|
|
14
|
+
|
|
15
|
+
const container = new Container();
|
|
16
|
+
|
|
17
|
+
container.register("userApi", () => new UserApi({ baseUrl: "/api" }));
|
|
18
|
+
container.register("logger", () => new ConsoleLogger());
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The factory is invoked **once per container** by default. The result is cached.
|
|
22
|
+
|
|
23
|
+
For per-resolve construction (non-singleton):
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
container.register("requestId", () => crypto.randomUUID(), false);
|
|
27
|
+
container.resolve("requestId"); // new id every time
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Resolving
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const api = container.resolve<UserApi>("userApi");
|
|
34
|
+
const logger = container.resolve<Logger>("logger");
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The generic parameter is for TypeScript only — there is no runtime type check.
|
|
38
|
+
|
|
39
|
+
Resolving an unregistered key throws:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
container.resolve("missing"); // Error: Dependency "missing" not registered
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Scopes — the core feature
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const requestScope = framework.container.createScope();
|
|
49
|
+
requestScope.register("currentUser", () => loadUserFromSession(request));
|
|
50
|
+
|
|
51
|
+
// Falls back to parent for keys not in the scope:
|
|
52
|
+
requestScope.resolve("userApi"); // parent
|
|
53
|
+
requestScope.resolve("currentUser"); // scope
|
|
54
|
+
|
|
55
|
+
requestScope.dispose();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A child scope:
|
|
59
|
+
|
|
60
|
+
- **Inherits** all parent keys via fallback resolution
|
|
61
|
+
- **Overrides** any key by registering its own factory
|
|
62
|
+
- **Cleans up** on `dispose()` — children are recursively disposed; the scope removes itself from the parent's children set
|
|
63
|
+
|
|
64
|
+
The framework creates a request-scoped container automatically per SSR request and passes it to your guards and controllers as `ctx.container` / the second argument of `execute()`. You should not need to manually create scopes for normal request handling.
|
|
65
|
+
|
|
66
|
+
## When to create your own scope
|
|
67
|
+
|
|
68
|
+
- Multi-tenant apps where each tenant has its own config / API client (see [advanced/multi-tenant-scopes](./advanced/multi-tenant-scopes.md))
|
|
69
|
+
- Long-running operations that need their own short-lived dependencies
|
|
70
|
+
- Test setup where you want to layer overrides on top of a base container
|
|
71
|
+
|
|
72
|
+
## Disposal and leaks
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const scope = container.createScope();
|
|
76
|
+
// ... use scope ...
|
|
77
|
+
scope.dispose();
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`dispose()`:
|
|
81
|
+
|
|
82
|
+
1. Recursively disposes any unfinished child scopes
|
|
83
|
+
2. Calls `destroy()` on any registered factory whose result implements it (loggers, recorders)
|
|
84
|
+
3. Removes itself from the parent's children set
|
|
85
|
+
4. Is idempotent — calling twice is safe
|
|
86
|
+
|
|
87
|
+
**Not disposing a scope leaks every cached value in it.** Request scopes that survive past the response will hold onto:
|
|
88
|
+
|
|
89
|
+
- HTTP clients (and their pending request state)
|
|
90
|
+
- Loggers / recorders
|
|
91
|
+
- Anything else the controllers resolved
|
|
92
|
+
|
|
93
|
+
The framework handles disposal for request scopes it creates. Scopes **you** create are yours to dispose.
|
|
94
|
+
|
|
95
|
+
See [pitfalls: container scope leak](./pitfalls/container-scope-leak.md) for the symptoms when you forget.
|
|
96
|
+
|
|
97
|
+
## Standard DI keys
|
|
98
|
+
|
|
99
|
+
Use `DEP_KEYS` constants instead of string literals to catch typos at type-check time:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
103
|
+
|
|
104
|
+
container.register(DEP_KEYS.LOGGER, () => new ConsoleLogger());
|
|
105
|
+
container.register(DEP_KEYS.EVENT_RECORDER, () => myRecorder);
|
|
106
|
+
|
|
107
|
+
const logger = container.resolve(DEP_KEYS.LOGGER);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The constants:
|
|
111
|
+
|
|
112
|
+
| Key | Standard type | Used by |
|
|
113
|
+
| ------------------------- | ------------------ | --------------------------------------------- |
|
|
114
|
+
| `DEP_KEYS.LOGGER` | `Logger` | Framework logging |
|
|
115
|
+
| `DEP_KEYS.LOGGER_FACTORY` | `LoggerFactory` | Per-category loggers (`logger.scope("auth")`) |
|
|
116
|
+
| `DEP_KEYS.NET` | `Net` | Network state checks (offline / metered) |
|
|
117
|
+
| `DEP_KEYS.STORAGE` | `Storage` | Key-value persistence (localStorage / memory) |
|
|
118
|
+
| `DEP_KEYS.FEATURE_FLAGS` | `FeatureFlags` | Feature flag reads |
|
|
119
|
+
| `DEP_KEYS.METRICS` | `MetricsClient` | Counter / gauge / timing |
|
|
120
|
+
| `DEP_KEYS.FETCH` | `typeof fetch` | `HttpClient`'s underlying fetch (mockable) |
|
|
121
|
+
| `DEP_KEYS.EVENT_RECORDER` | `EventRecorder` | Structured event recording |
|
|
122
|
+
| `DEP_KEYS.LOCALE` | `LocaleAttributes` | Resolved locale (lang + dir) |
|
|
123
|
+
| `DEP_KEYS.PLATFORM` | `PlatformInfo` | Detected user-agent platform info |
|
|
124
|
+
| `DEP_KEYS.TRANSLATOR` | `Translator` | Translation function |
|
|
125
|
+
|
|
126
|
+
The framework registers default implementations for these during `Framework.create()`. Override them by registering after framework creation:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const framework = Framework.create({
|
|
130
|
+
/* ... */
|
|
131
|
+
});
|
|
132
|
+
framework.container.register(DEP_KEYS.LOGGER, () => myCustomLogger);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Custom keys
|
|
136
|
+
|
|
137
|
+
For your own services, use string keys directly:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
container.register("userApi", () => new UserApi({ baseUrl: "/api" }));
|
|
141
|
+
container.register("session", () => new SessionService());
|
|
142
|
+
container.register("featureBucketing", () => new BucketingService());
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
To get type safety for your own keys, define your own const map:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// src/lib/di-keys.ts
|
|
149
|
+
export const APP_KEYS = {
|
|
150
|
+
USER_API: "userApi",
|
|
151
|
+
SESSION: "session",
|
|
152
|
+
FEATURE_BUCKETING: "featureBucketing",
|
|
153
|
+
} as const;
|
|
154
|
+
|
|
155
|
+
// Usage
|
|
156
|
+
container.register(APP_KEYS.USER_API, () => new UserApi({ baseUrl: "/api" }));
|
|
157
|
+
const api = container.resolve<UserApi>(APP_KEYS.USER_API);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Lifecycle ordering
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
Framework.create({ ... })
|
|
164
|
+
│
|
|
165
|
+
▼
|
|
166
|
+
default DEP_KEYS registered (logger, locale, platform, ...)
|
|
167
|
+
│
|
|
168
|
+
▼
|
|
169
|
+
your custom registrations (in onBeforeStart or bootstrap)
|
|
170
|
+
│
|
|
171
|
+
▼
|
|
172
|
+
─── per request ───────────────────────────────────
|
|
173
|
+
framework.container.createScope() ← request scope
|
|
174
|
+
│
|
|
175
|
+
▼
|
|
176
|
+
beforeLoad guards (ctx.container = scope)
|
|
177
|
+
│
|
|
178
|
+
▼
|
|
179
|
+
controller.execute(params, scope)
|
|
180
|
+
│
|
|
181
|
+
▼
|
|
182
|
+
afterLoad guards (ctx.container = scope)
|
|
183
|
+
│
|
|
184
|
+
▼
|
|
185
|
+
renderApp() / response
|
|
186
|
+
│
|
|
187
|
+
▼
|
|
188
|
+
scope.dispose() ← framework cleans up
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Testing with the container
|
|
192
|
+
|
|
193
|
+
Inject mocks at the scope level:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { Framework } from "@finesoft/front";
|
|
197
|
+
|
|
198
|
+
const framework = Framework.create({
|
|
199
|
+
/* ... */
|
|
200
|
+
});
|
|
201
|
+
const testScope = framework.container.createScope();
|
|
202
|
+
testScope.register("userApi", () => mockUserApi);
|
|
203
|
+
|
|
204
|
+
const controller = new UserListController();
|
|
205
|
+
const page = await controller.execute({}, testScope);
|
|
206
|
+
|
|
207
|
+
testScope.dispose();
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The full pattern is in [engineering/testing](./engineering/testing.md).
|
|
211
|
+
|
|
212
|
+
## Antipatterns
|
|
213
|
+
|
|
214
|
+
### Don't resolve in module top-level
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// BAD — runs at import time, before framework.create()
|
|
218
|
+
const logger = container.resolve(DEP_KEYS.LOGGER);
|
|
219
|
+
export function log(msg: string) {
|
|
220
|
+
logger.info(msg);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The `container` you'd reach here isn't the request scope; you'd get the parent and lose request isolation.
|
|
225
|
+
|
|
226
|
+
Instead, resolve inside the function that has access to the scope:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
export function logFromController(container: Container, msg: string) {
|
|
230
|
+
container.resolve<Logger>(DEP_KEYS.LOGGER).info(msg);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Don't keep refs to scoped instances after dispose
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// BAD
|
|
238
|
+
let api: UserApi;
|
|
239
|
+
beforeLoad: (ctx) => {
|
|
240
|
+
api = ctx.container.resolve("userApi");
|
|
241
|
+
return next();
|
|
242
|
+
};
|
|
243
|
+
// `api` now points at an instance whose scope was disposed
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
If you need to share state across requests, register it on the parent container, not the request scope.
|
|
247
|
+
|
|
248
|
+
### Don't register inside a guard
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
// BAD — runs per request
|
|
252
|
+
beforeLoad: (ctx) => {
|
|
253
|
+
ctx.container.register("userApi", () => new UserApi(/*...*/));
|
|
254
|
+
return next();
|
|
255
|
+
};
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This creates a fresh factory closure per request. Register once at framework setup; the scope inherits.
|
|
259
|
+
|
|
260
|
+
## Next
|
|
261
|
+
|
|
262
|
+
- [Observability](./08-observability.md) — wiring Logger / EventRecorder / ReportCallback via DI
|
|
263
|
+
- [Engineering: testing](./engineering/testing.md) — using scopes to isolate tests
|
|
264
|
+
- [Pitfalls: container scope leak](./pitfalls/container-scope-leak.md) — what happens when you forget to dispose
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# 8. Observability
|
|
2
|
+
|
|
3
|
+
Three primitives, each composable, each replaceable:
|
|
4
|
+
|
|
5
|
+
- **`Logger`** — line-oriented (debug/info/warn/error)
|
|
6
|
+
- **`EventRecorder`** — structured event records (`{ name, fields }`)
|
|
7
|
+
- **`ReportCallback`** — fan-out of `warn`/`error` logs to external monitoring
|
|
8
|
+
|
|
9
|
+
Plus impression tracking via `IntersectionImpressionObserver`.
|
|
10
|
+
|
|
11
|
+
## Logger
|
|
12
|
+
|
|
13
|
+
The base `ConsoleLogger` writes to `console.{debug,info,warn,error}` with a category prefix. Scoped loggers add nested prefixes.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { ConsoleLogger } from "@finesoft/front";
|
|
17
|
+
|
|
18
|
+
const logger = new ConsoleLogger("app");
|
|
19
|
+
logger.info("hello"); // [app] hello
|
|
20
|
+
|
|
21
|
+
const auth = logger.scope("auth");
|
|
22
|
+
auth.warn("token expired"); // [app:auth] token expired
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Levels: `debug`, `info`, `warn`, `error`. The framework calls `warn`/`error` on its own logger for internal failures (failed guards, dispatch errors).
|
|
26
|
+
|
|
27
|
+
### Replacing the framework logger
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
31
|
+
|
|
32
|
+
framework.container.register(DEP_KEYS.LOGGER, () => myLogger);
|
|
33
|
+
framework.container.register(DEP_KEYS.LOGGER_FACTORY, () => ({
|
|
34
|
+
create(category) {
|
|
35
|
+
return myLoggerImpl.scope(category);
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## `ReportCallback` — forwarding logs to monitoring
|
|
41
|
+
|
|
42
|
+
The cleanest way to ship `warn`/`error` logs to Sentry / Datadog / your own collector:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { Framework, type ReportCallback } from "@finesoft/front";
|
|
46
|
+
|
|
47
|
+
const reportCallback: ReportCallback = (level, category, args) => {
|
|
48
|
+
Sentry.captureMessage(`[${category}] ${args.join(" ")}`, level);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const framework = Framework.create({
|
|
52
|
+
reportCallback,
|
|
53
|
+
// ...
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
How it works:
|
|
58
|
+
|
|
59
|
+
- `Framework.create({ reportCallback })` installs `ReportingLoggerFactory` over `ConsoleLogger`
|
|
60
|
+
- All `logger.warn(...)` and `logger.error(...)` calls fire the callback **and** continue to the console
|
|
61
|
+
- `debug` and `info` are not forwarded (avoid burying signal in volume)
|
|
62
|
+
- The callback runs inside `try/catch` — a failure in the reporter cannot crash the framework
|
|
63
|
+
|
|
64
|
+
### Categories
|
|
65
|
+
|
|
66
|
+
Each `logger.scope("auth")` becomes a category passed to the callback. Use them for routing in your monitoring system:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
reportCallback(level, category, args) {
|
|
70
|
+
if (category.startsWith("auth")) {
|
|
71
|
+
sentry.captureMessage(/* go to auth project */);
|
|
72
|
+
} else {
|
|
73
|
+
sentry.captureMessage(/* default project */);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Anti-spam
|
|
79
|
+
|
|
80
|
+
`ReportCallback` runs every time. If you have a hot loop logging the same warning a thousand times, you'll send a thousand events. Add deduplication in the callback itself:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const seen = new Map<string, number>();
|
|
84
|
+
const reportCallback: ReportCallback = (level, category, args) => {
|
|
85
|
+
const key = `${category}:${args[0]}`;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const last = seen.get(key) ?? 0;
|
|
88
|
+
if (now - last < 60_000) return; // 1 per minute per (category, message)
|
|
89
|
+
seen.set(key, now);
|
|
90
|
+
Sentry.captureMessage(/* ... */);
|
|
91
|
+
};
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## `EventRecorder` — structured events
|
|
95
|
+
|
|
96
|
+
When you want events with fields, not free-form log lines, use `EventRecorder`.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { Framework, ConsoleEventRecorder, type EventRecorder } from "@finesoft/front";
|
|
100
|
+
|
|
101
|
+
const recorder: EventRecorder = new ConsoleEventRecorder();
|
|
102
|
+
const framework = Framework.create({ eventRecorder: recorder });
|
|
103
|
+
|
|
104
|
+
// Anywhere:
|
|
105
|
+
recorder.record({
|
|
106
|
+
name: "PageView",
|
|
107
|
+
fields: { url: "/products/42", referrer: "/search?q=widget" },
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Built-in events
|
|
112
|
+
|
|
113
|
+
The framework records `PageView` automatically via `framework.didEnterPage(page)`. This fires:
|
|
114
|
+
|
|
115
|
+
- After every successful navigation (SSR + CSR)
|
|
116
|
+
- With fields: `{ intentId, url, renderMode }`
|
|
117
|
+
|
|
118
|
+
### Composing recorders
|
|
119
|
+
|
|
120
|
+
`CompositeEventRecorder` fans events to multiple sinks:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { ConsoleEventRecorder, CompositeEventRecorder } from "@finesoft/front";
|
|
124
|
+
|
|
125
|
+
const recorder = new CompositeEventRecorder([
|
|
126
|
+
new ConsoleEventRecorder(), // dev visibility
|
|
127
|
+
productionAnalyticsRecorder, // ship to backend
|
|
128
|
+
]);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
If one recorder throws, the others still receive the event.
|
|
132
|
+
|
|
133
|
+
### Adding common fields
|
|
134
|
+
|
|
135
|
+
`WithFieldsRecorder` decorates another recorder, prepending fields to every event:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { WithFieldsRecorder, ConsoleEventRecorder } from "@finesoft/front";
|
|
139
|
+
|
|
140
|
+
const recorder = new WithFieldsRecorder(new ConsoleEventRecorder(), [
|
|
141
|
+
{ getFields: () => ({ app: "myApp", version: "1.0.0" }) },
|
|
142
|
+
{ getFields: () => ({ userId: getCurrentUserId() }) },
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
recorder.record({ name: "Click", fields: { id: "buy" } });
|
|
146
|
+
// → { app, version, userId, id }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Use this for session-scoped fields you don't want to repeat in every `record()` call.
|
|
150
|
+
|
|
151
|
+
### Building your own recorder
|
|
152
|
+
|
|
153
|
+
Implement the `EventRecorder` interface:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import type { EventRecorder, EventRecord } from "@finesoft/front";
|
|
157
|
+
|
|
158
|
+
class HttpEventRecorder implements EventRecorder {
|
|
159
|
+
constructor(private endpoint: string) {}
|
|
160
|
+
record(event: EventRecord): void {
|
|
161
|
+
// fire-and-forget; do NOT await — record() shouldn't block UI
|
|
162
|
+
navigator.sendBeacon(this.endpoint, JSON.stringify(event));
|
|
163
|
+
}
|
|
164
|
+
destroy(): void {
|
|
165
|
+
// flush, close connections, etc.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
See [advanced/custom-event-recorder](./advanced/custom-event-recorder.md) for batching, retries, and lifecycle handling.
|
|
171
|
+
|
|
172
|
+
## Impression tracking
|
|
173
|
+
|
|
174
|
+
Track when elements enter the viewport using `IntersectionObserver` plumbing:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { IntersectionImpressionObserver, type EventRecorder } from "@finesoft/front";
|
|
178
|
+
|
|
179
|
+
const observer = new IntersectionImpressionObserver((entries) => {
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
recorder.record({
|
|
182
|
+
name: "Impression",
|
|
183
|
+
fields: { id: entry.id, ...entry.metadata },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Attach to an element when it mounts
|
|
189
|
+
observer.observe(productCardElement, "product-card-123", {
|
|
190
|
+
category: "featured",
|
|
191
|
+
position: 3,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Detach when it unmounts
|
|
195
|
+
observer.unobserve(productCardElement);
|
|
196
|
+
|
|
197
|
+
// On app teardown
|
|
198
|
+
observer.destroy();
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The observer fires the callback once per element per visibility transition. It does not deduplicate across observe/unobserve cycles — your callback should decide whether to record again.
|
|
202
|
+
|
|
203
|
+
## Error handling via `fallback`
|
|
204
|
+
|
|
205
|
+
A controller error becomes a recorded event automatically:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
class HomeController extends BaseController<{}, HomePage> {
|
|
209
|
+
readonly intentId = "home";
|
|
210
|
+
|
|
211
|
+
async execute(_params, container) {
|
|
212
|
+
const api = container.resolve<UserApi>("userApi");
|
|
213
|
+
return { kind: "home", users: await api.list() };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fallback(_params, error) {
|
|
217
|
+
// 1. framework logs the error via its logger (→ reportCallback)
|
|
218
|
+
// 2. you return a degraded page
|
|
219
|
+
return { kind: "home", users: [], degraded: true };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Order of operations on error:
|
|
225
|
+
|
|
226
|
+
1. `execute()` throws.
|
|
227
|
+
2. `BaseController` catches, calls `framework.logger.error("[controller:home]", error)`.
|
|
228
|
+
3. Logger fires `reportCallback("error", "controller:home", [error])` → Sentry.
|
|
229
|
+
4. `fallback()` returns a `Page` of your design.
|
|
230
|
+
5. Render proceeds with the fallback page (HTTP 200 by default).
|
|
231
|
+
|
|
232
|
+
To set a non-200 status, return a page with an "error" marker and check it in an `afterLoad` guard:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
afterLoad: [
|
|
236
|
+
(ctx) => {
|
|
237
|
+
if ("degraded" in ctx.page && ctx.page.degraded) {
|
|
238
|
+
return deny(503, "Service degraded");
|
|
239
|
+
}
|
|
240
|
+
return next();
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Metrics (counters / gauges / timing)
|
|
246
|
+
|
|
247
|
+
The framework's `MetricsClient` is minimal:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
251
|
+
|
|
252
|
+
const metrics = framework.container.resolve(DEP_KEYS.METRICS);
|
|
253
|
+
|
|
254
|
+
metrics.increment("requests.total");
|
|
255
|
+
metrics.increment("login.failed", { reason: "wrong_password" });
|
|
256
|
+
metrics.gauge("queue.depth", 17);
|
|
257
|
+
metrics.timing("render.duration", 142);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The default implementation is a no-op. Register your own:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
framework.container.register(DEP_KEYS.METRICS, () => new StatsdMetrics(/* ... */));
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
For most apps, `EventRecorder` is enough — your analytics backend already aggregates events into metrics.
|
|
267
|
+
|
|
268
|
+
## Trace IDs and request correlation
|
|
269
|
+
|
|
270
|
+
A common pattern: tag every log and event with a per-request trace id.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
// beforeLoad guard
|
|
274
|
+
function traceIdGuard(ctx) {
|
|
275
|
+
const traceId = ctx.getHeader("x-request-id") ?? crypto.randomUUID();
|
|
276
|
+
ctx.container.register("traceId", () => traceId);
|
|
277
|
+
ctx.container.register(
|
|
278
|
+
DEP_KEYS.EVENT_RECORDER,
|
|
279
|
+
() => new WithFieldsRecorder(baseRecorder, [{ getFields: () => ({ traceId }) }]),
|
|
280
|
+
);
|
|
281
|
+
return next();
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Register the scoped recorder via `WithFieldsRecorder` so every event in this request automatically carries the trace id.
|
|
286
|
+
|
|
287
|
+
## Next
|
|
288
|
+
|
|
289
|
+
- [Server & deployment](./09-server-and-deployment.md) — where these primitives meet HTTP
|
|
290
|
+
- [Advanced: custom event recorder](./advanced/custom-event-recorder.md) — production-grade implementation
|