docs(recipes): add observability flags boundary
Document provider-explicit observability, feature flag, A/B, and killswitch boundaries for hemx apps. The recipe keeps metrics providers and rollout controls in app/platform code while handlers return generated effects and the tiny runtime stays provider-free. req: laws/002 req: laws/004 req: runtime/003 req: runtime/004 req: failure/004 req: examples/001 req: test/001
This commit is contained in:
@@ -87,7 +87,7 @@ and integrate at explicit boundaries. req: laws/002 req: auth/001
|
|||||||
fields and credentials semantics. See `docs/recipes/auth-session-csrf.md`. req: auth/004 req: auth/005
|
fields and credentials semantics. See `docs/recipes/auth-session-csrf.md`. req: auth/004 req: auth/005
|
||||||
- **Observability, feature flags, killswitches, deploy:** use explicit platform
|
- **Observability, feature flags, killswitches, deploy:** use explicit platform
|
||||||
integrations around handlers, routes, and runtime assets. Core hemx must not
|
integrations around handlers, routes, and runtime assets. Core hemx must not
|
||||||
vendor providers or add framework-specific magic. See `docs/recipes/deploy-versioning.md`.
|
vendor providers or add framework-specific magic. See `docs/recipes/observability-flags.md` and `docs/recipes/deploy-versioning.md`.
|
||||||
- **PWA/offline/sync:** optional adapters may reuse generated targets/effects,
|
- **PWA/offline/sync:** optional adapters may reuse generated targets/effects,
|
||||||
but core hemx must not gain a mandatory client state graph or local app
|
but core hemx must not gain a mandatory client state graph or local app
|
||||||
runtime. req: canonical_authoring/008
|
runtime. req: canonical_authoring/008
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Recipe: observability, feature flags, and killswitches
|
||||||
|
|
||||||
|
This recipe shows where production telemetry and rollout controls belong in a
|
||||||
|
hemx app. Metrics, traces, feature flags, A/B assignment, and killswitches are
|
||||||
|
application/platform integrations, not hemx core features. hemx should expose a
|
||||||
|
small effect boundary, preserve normal HTTP behavior, and leave provider choice
|
||||||
|
to the app. req: laws/002 req: laws/004
|
||||||
|
|
||||||
|
Use this with `examples/saas` after the auth/session, CSRF, persistence, and
|
||||||
|
deploy/versioning boundaries are in place.
|
||||||
|
|
||||||
|
## Boundary rule
|
||||||
|
|
||||||
|
Keep these concerns outside hemx crates:
|
||||||
|
|
||||||
|
- metrics/tracing providers such as OpenTelemetry, Datadog, Prometheus, Honeycomb,
|
||||||
|
or platform logs
|
||||||
|
- feature flag providers and assignment stores
|
||||||
|
- A/B test bucketing and analytics destinations
|
||||||
|
- rollout and killswitch policy
|
||||||
|
- alerting, dashboards, and incident response
|
||||||
|
|
||||||
|
Keep these concerns in app/integration code:
|
||||||
|
|
||||||
|
- route and handler spans
|
||||||
|
- effect-response counters
|
||||||
|
- provider-specific labels and sampling policy
|
||||||
|
- generated UI effects that show degraded or disabled states
|
||||||
|
- app-owned flags passed through typed state or extractors
|
||||||
|
|
||||||
|
The normal handler shape remains typed Rust returning generated effects.
|
||||||
|
|
||||||
|
## Instrument routes and dispatch, not the runtime
|
||||||
|
|
||||||
|
Instrument the server boundary around ordinary Axum routes and hemx interaction
|
||||||
|
dispatch. The browser runtime should not become an analytics SDK.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn interact(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
session: CurrentSession,
|
||||||
|
request: InteractionRequest,
|
||||||
|
) -> Result<EffectResponse, impl IntoResponse> {
|
||||||
|
let handle_id = request.handle_id();
|
||||||
|
let span = tracing::info_span!(
|
||||||
|
"hemx.interaction",
|
||||||
|
handle_id,
|
||||||
|
user_id = %session.user_id,
|
||||||
|
release = %app.release_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let ctx = AppContext::new(session, app.store.clone(), app.flags.clone());
|
||||||
|
let result = request.dispatch_async(registry(ctx)).await;
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(_) => metrics::counter!("hemx.interaction.ok").increment(1),
|
||||||
|
Err(_) => metrics::counter!("hemx.interaction.error").increment(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
.instrument(span)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact crates are app choices. The important part is that observability wraps
|
||||||
|
routes, handlers, and provider adapters instead of adding client-side state or
|
||||||
|
selector-based probes. req: runtime/003 req: runtime/004
|
||||||
|
|
||||||
|
## Feature flags as typed app state
|
||||||
|
|
||||||
|
Flags should be ordinary typed state. Handlers read the flag and return generated
|
||||||
|
UI effects or normal HTTP responses.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FeatureFlags {
|
||||||
|
project_creation: bool,
|
||||||
|
beta_metrics_island: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppContext {
|
||||||
|
session: CurrentSession,
|
||||||
|
store: ProjectStore,
|
||||||
|
flags: FeatureFlags,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[hemx::handler]
|
||||||
|
async fn create_project(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<NewProject>,
|
||||||
|
) -> Result<impl IntoEffect, AppError> {
|
||||||
|
if !ctx.flags.project_creation {
|
||||||
|
return Ok((
|
||||||
|
dashboard::flash.set("Project creation is temporarily disabled"),
|
||||||
|
dashboard::new_project.disable_while_pending(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.verify_csrf(&form.csrf)?;
|
||||||
|
let project = ctx.store.insert(form.name, &ctx.session).await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
dashboard::project_row.append(ProjectRow::from(project)),
|
||||||
|
dashboard::new_project.clear(),
|
||||||
|
dashboard::flash.set("Project created"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A flag provider may refresh `FeatureFlags` from a database, config service, or
|
||||||
|
static file. hemx does not need a flag API; the generated helpers are enough to
|
||||||
|
show enabled, disabled, or degraded UI.
|
||||||
|
|
||||||
|
## Killswitches
|
||||||
|
|
||||||
|
A killswitch is a product decision at the application boundary. Prefer explicit
|
||||||
|
failure or degraded UI over silently dropping effects.
|
||||||
|
|
||||||
|
Good killswitch targets:
|
||||||
|
|
||||||
|
- disable one mutation handler while leaving page rendering intact
|
||||||
|
- switch from enhanced interaction to full-page form response
|
||||||
|
- disable an island or live status stream while keeping the server-rendered page
|
||||||
|
usable
|
||||||
|
- pause SSE/polling and show a generated status message
|
||||||
|
|
||||||
|
Example for an SSE/live-status killswitch:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn live_status(ctx: &AppContext) -> impl IntoEffect {
|
||||||
|
if !ctx.flags.live_status {
|
||||||
|
return dashboard::live_status.set("Live status is paused");
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboard::live_status.set(format!("heartbeat: {} projects", ctx.projects().len()))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add a generic client-side killswitch to the runtime. The runtime applies
|
||||||
|
checked effects; the app decides which effects to produce. req: failure/004
|
||||||
|
|
||||||
|
## A/B tests and analytics
|
||||||
|
|
||||||
|
A/B assignment belongs in auth/session or request context:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ExperimentContext {
|
||||||
|
variant: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[hemx::handler]
|
||||||
|
async fn open_settings(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> impl IntoEffect {
|
||||||
|
let panel = if ctx.experiments.variant == "compact" {
|
||||||
|
SettingsPage::compact()
|
||||||
|
} else {
|
||||||
|
SettingsPage::full()
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
dashboard::page_panel.put(&panel),
|
||||||
|
dashboard::nav.set("Settings"),
|
||||||
|
hemx::push("/settings"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Analytics can be emitted server-side when the handler runs or through explicit
|
||||||
|
native events returned by the handler. Avoid hidden DOM scraping or selector
|
||||||
|
listeners as the normal path.
|
||||||
|
|
||||||
|
## Metrics to track
|
||||||
|
|
||||||
|
Suggested app/platform metrics:
|
||||||
|
|
||||||
|
- `hemx.interaction.ok`
|
||||||
|
- `hemx.interaction.error`
|
||||||
|
- `hemx.form.parse_error`
|
||||||
|
- `hemx.handler.failure`
|
||||||
|
- `hemx.fingerprint_mismatch`
|
||||||
|
- `hemx.missing_target`
|
||||||
|
- `hemx.sse.reconnect`
|
||||||
|
- `hemx.killswitch.active`
|
||||||
|
|
||||||
|
Provider names, label sets, sampling, and retention are platform decisions. Do
|
||||||
|
not bake them into hemx core.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Keep tests at the app boundary:
|
||||||
|
|
||||||
|
- flag disabled: handler does not call the store and returns a generated disabled
|
||||||
|
or flash effect
|
||||||
|
- flag enabled: handler follows the normal generated-helper path
|
||||||
|
- killswitch active: live status or island is paused with generated UI feedback
|
||||||
|
- provider failure: app maps the failure through `AppError` without panicking
|
||||||
|
- metrics wrapper records ok/error paths without changing effect contents
|
||||||
|
|
||||||
|
`examples/saas` can exercise those checks with an in-memory fake flag provider;
|
||||||
|
a real deployment can use the same tests around a provider-backed `FeatureFlags`
|
||||||
|
loader. req: examples/001 req: test/001
|
||||||
@@ -12,7 +12,7 @@ What it proves today:
|
|||||||
- page shell with plain CSS and one explicit metrics island script
|
- page shell with plain CSS and one explicit metrics island script
|
||||||
- compile-time surface generation plus interaction tests
|
- compile-time surface generation plus interaction tests
|
||||||
|
|
||||||
For provider-explicit boundaries, see `../../docs/recipes/sqlx-persistence.md`, `../../docs/recipes/auth-session-csrf.md`, and `../../docs/recipes/deploy-versioning.md`.
|
For provider-explicit boundaries, see `../../docs/recipes/sqlx-persistence.md`, `../../docs/recipes/auth-session-csrf.md`, `../../docs/recipes/observability-flags.md`, and `../../docs/recipes/deploy-versioning.md`.
|
||||||
|
|
||||||
What it deliberately does not claim yet:
|
What it deliberately does not claim yet:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user