> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Observability

> Internal architecture and configuration for Stralos analytics, replay, errors, and structured logs.

## Service coverage

Stralos uses PostHog for product analytics, session replay, browser error tracking, and
structured application logs:

* `stralos-shell` owns the browser SDK for the shell and all micro-frontends.
* `stralos-ui` exposes an error-boundary reporting hook without depending on PostHog.
* `stralos-api-global` emits server exceptions and structured HTTP completion logs.
* `stralos-api-activities`, `stralos-api-assistant`, `stralos-api-documents`,
  `stralos-api-estimate`, `stralos-api-rates`, and `stralos-api-scheduler` use the
  shared API observability plugin for bounded request events and server exceptions.
* `stralos-feature-dashboard` is covered by the shell. It must not initialise another
  browser SDK.

Capture is fail-closed, environment specific, additive, and independent of
application persistence.

## Project and environment model

Use one PostHog application project per deployment environment, not one project per
micro-frontend or API.

| Environment | Project |
| - | - |
| Local development | None by default; capture is off |
| Test | `Stralos App - Test` |
| Production | `Stralos App - Production` |

Keep the public landing-site analytics project separate. Use separate Admin Test and
Admin Production projects for the admin application.

The Test project should use:

* 100% session replay sampling, controlled in PostHog
* session recording enabled
* automatic pageviews disabled because the shell emits route-aware pageviews
* autocapture disabled
* automatic console capture disabled

Do not change the replay sampling percentage in application code.

## Browser ownership

The shell initialises `posthog-js` once via `@stralos-co-nz/observability/host`. The shell's
`src/observability/posthog.ts` is a thin adapter: it resolves runtime configuration and the
replay mask function, then delegates to `initializeObservability` from that package. Every
micro-frontend executes in that host runtime and inherits the same session, person,
organisation group, release, and environment context.

The shell captures:

* manual `$pageview` events after router navigation
* CLS, FCP, INP, LCP, and TTFB as `web_vitals`
* global `window.error` and `unhandledrejection` failures
* React error-boundary failures, including the original Module Federation cause
* the verified user as the PostHog distinct ID, with `name`, `email`, and `role` as person properties
* the verified organisation through the `organisation` group, with the organisation name as a group property

Logout and organisation switching reset the PostHog identity. A remote must never call
`posthog.init`, identify a person independently, or start a second replay recorder.

## Feature flags

The shell publishes its PostHog instance and a ready event so micro-frontend remotes can evaluate feature flags without initialising a second PostHog SDK.

### Window interface

The shell writes to `window.__stralosPosthog` and dispatches `stralos:posthog-ready` each time flag evaluation settles. Remotes read both through `useFeatureFlag` in `@stralos-co-nz/ui`. Nothing else in the estate may create a second PostHog instance.

`window.__stralosPosthog` has three distinct states:

| Value | Meaning |
| - | - |
| `undefined` | The shell has not yet decided. A consumer waits. |
| `null` | Flags are unavailable for the current evaluation. A consumer takes its fallback. |
| PostHog instance | Flags are being evaluated. A consumer subscribes to `onFeatureFlags`. |

The `null` state covers every path that ends without flags: disabled runtime configuration, an invalid token, a hostname mismatch, a failed `init`, a failed subscription registration, a flag request that came back with `errorsLoading`, and any boot where the shell skips observability entirely (for example, when an address-bar scrub fails). A consumer that receives no signal at all holds its loading state for the life of the page, so silence is never an acceptable outcome.

### Ready event

`stralos:posthog-ready` fires on every settle, not only the first. A flag re-evaluated after `identify()` reaches consumers that mounted before the person was known.

### Error handling

A flag request that fails still settles — PostHog calls back with `errorsLoading` and no flags. The shell treats that outcome the same as disabled observability and publishes `null`, so consumers take their fallback rather than silently reading every flag as off. A later refresh that succeeds hands the instance back.

### Consuming flags

Remotes read flags through `useFeatureFlag` from `@stralos-co-nz/ui`, which handles the three-state window value and subscribes to the ready event. Do not read `window.__stralosPosthog` directly or call `posthog.isFeatureEnabled` outside that hook.

### Flag naming and lifecycle

Name flag keys `<remote>-<feature>`, for example `estimate-council-utilities`. Create the key in PostHog before releasing its consumer, assign an owner and a planned removal release, and start it disabled with `fallback: false`.

The lifecycle is: create → staff rollout → percentage rollout → 100% → remove the flag and its conditional code within one release. During the staff rollout, target the intended signed-in staff population in PostHog. Membership in the staff cohort comes only from the shell's post-login email-domain check; changing a PostHog audience must not determine who receives the `stralos-cohort=staff` cookie. See [Staff cohort](https://help.stralos.app/internal/staff-cohort.md) for how the cookie is set and what it selects.

Test the unknown, off, and on states. Prefer a positive flag with an off fallback over an inverted kill switch. Record the rollback decision and owner before expanding the percentage. Flags control browser behaviour; the API enforces authorisation regardless of flag or cohort.

### Flag change annotations

Record every flag change in PostHog's project annotation timeline using the deploy-marker convention from `stralos-core/scripts/observability/deploy-marker.mjs`. Include:

* environment, flag key, previous and new targeting, and percentage
* actor, timestamp, release version or SHA
* run or change-record URL and correlation

Record disable and removal as well as expansion. Annotation failures are observability failures; they do not change access or flag values. Keep the change record when retrying an annotation.

The deploy marker accepts deployment kinds `api`, `remote`, `shell`, and `composition`. Record flag changes as explicit annotations. The current implementation does not offer a `flag` dispatch kind.

Hosts, project IDs, and personal API keys are operator configuration. They must not appear as frontend literals.

## Replay capture boundary

Replay masking is produced by `createReplayMasker` from `@stralos-co-nz/observability/host`,
called with the shell's declared navigation labels. The shell passes the resulting function to
`initializeObservability` from the same package. The active policy for the current beta is
described below.

**Beta (current)** records session content as readable text in every environment, including
production (`@stralos-co-nz/observability` 0.1.1, pinned by the shell), so product and engineering
teams can diagnose issues without constant masking noise. At beta exit, the production masking
switch is restored in the observability package and the shell's pin is raised. The policy is:

* `maskAllInputs: false` — inputs are not blanket-masked.
* `maskInputOptions: { password: true }` — password-type inputs are always masked regardless of
  the above setting.
* No `maskTextFn` — text nodes are not routed through an allowlist.
* `maskTextSelector: "[data-ph-mask], [data-ph-mask] *"` — any element or descendant
  explicitly marked `data-ph-mask` is masked. Use this attribute on any surface containing
  tenant data, personal information, or other content that must not appear in replay.

`maskAllInputs: false` is not "mask nothing": rrweb resolves it to mask password-type inputs,
so login passwords are never captured. A field whose type is swapped at runtime is also caught
by its `data-rr-is-password` attribute.

All environments block elements marked with `data-ph-credential` or `data-ph-no-capture`
entirely via `blockSelector: "[data-ph-no-capture], [data-ph-credential]"`. Use those markers
for OTP, MFA, token, and any other element that must never appear in replay.
Review this boundary whenever a new credential, security, payment, or other sensitive-data
surface is introduced.

Contractor portal links contain bearer credentials. The estimate remote must never
render those tokens as text, must mark token-bearing links with `data-ph-no-capture`,
and must replace the credential segment before an API response URL or structured error
body reaches visible error text. The estimate API must use generic portal-not-found
messages and never echo the supplied capability token.

The browser SDK also disables replay capture of request and response bodies and headers
in code. Its network hook strips URL queries and fragments and replaces dynamic path
identifiers before a network timing record can be captured. Do not attach bodies,
headers, cookies, authentication material, payment data, or browser-storage contents to
events, errors, or logs.

## Runtime configuration

Deployed frontends load environment-specific configuration from an injected
`window.__STRALOS_OBSERVABILITY__` value or `/runtime-config.json`:

```json theme={null}
{
  "enabled": true,
  "environment": "test",
  "projectToken": "<public project token>",
  "apiHost": "https://helpme.stralos.app",
  "allowedHostnames": ["au-nz.test.stralos.app"],
  "tracingHostnames": ["<Test API hostname>"],
  "release": "<immutable release version>"
}
```

The shell validates the token shape, ingestion URL, environment, release, and current
hostname. Missing or invalid configuration disables capture. Vite environment variables
are an explicit local opt-in only:

```text theme={null}
VITE_POSTHOG_ENABLED=true
VITE_DEPLOYMENT_ENVIRONMENT=test
VITE_POSTHOG_PROJECT_TOKEN=<public project token>
VITE_POSTHOG_HOST=https://helpme.stralos.app
VITE_POSTHOG_ALLOWED_HOSTNAMES=localhost,127.0.0.1
VITE_POSTHOG_TRACING_HOSTNAMES=localhost,127.0.0.1
VITE_RELEASE_VERSION=<release version>
```

Use the first-party `https://helpme.stralos.app` ingestion host. Changing the runtime
host does not change SDK ownership or the event contract.

`tracingHostnames` contains exact, trusted API hostnames. The browser SDK adds bounded
PostHog session, window, and distinct-ID headers only to those hosts. Do not use
wildcards or third-party hosts.

## API configuration

`stralos-api-global` resolves its project token at cold start. An inline
`POSTHOG_PROJECT_TOKEN` takes precedence over the SSM parameter named by
`POSTHOG_PROJECT_TOKEN_SSM_PARAMETER`. Terraform grants access only to that parameter.

The other covered APIs receive `POSTHOG_PROJECT_TOKEN` directly in their Lambda
environment from Terraform. An empty or invalid token disables capture. They share
these controls:

```text theme={null}
POSTHOG_PROJECT_TOKEN=<public project token>
POSTHOG_HOST=<PostHog ingestion host>
POSTHOG_REQUESTS_ENABLED=true
POSTHOG_ERROR_TRACKING_ENABLED=true
POSTHOG_PAYLOADS_ENABLED=false
```

Global calls its request flag `POSTHOG_LOGS_ENABLED`; the remaining covered APIs use
`POSTHOG_REQUESTS_ENABLED`. All capture flags default to false. Payload capture is
redacted and bounded, and the shared SDK refuses it outside development and test even
if the flag is set. Keep it disabled in production.

Each eligible request emits `http.request.completed` with the Fastify route template,
method, status, duration, outcome, request ID, and verified user and organisation when
authentication succeeds. Supplied PostHog correlation headers remain untrusted hints
and never establish identity. Unhandled 5xx responses emit a sanitised native exception
and `app.operation.failed`.

API Lambdas send telemetry after the response has gone back to the client. Each
`lambda.ts` awaits `startTelemetryExtension()` from `@stralos-co-nz/api-common` (0.18.2 or
later) at INIT, which registers an internal Lambda extension in the same process. The
handler's `finally` block calls `finishTelemetry()`, which hands the flush to the extension
and returns at once. Lambda returns the response as soon as the handler finishes, but keeps
the environment running until the extension has finished the send, up to 1.5 seconds after
the response. The request no longer waits on telemetry, and the environment does not freeze
with a send in flight.

The extension sends batches to the telemetry SQS queue named by `TELEMETRY_QUEUE_URL`. At
INIT it also warms the SQS connection with a `GetQueueAttributes` call that overlaps the
rest of cold start, so each API's telemetry IAM policy grants `sqs:GetQueueAttributes`
alongside `sqs:SendMessage` on that queue.

The extension stays inactive when `TELEMETRY_AFTER_RESPONSE=false` (the kill switch), when
`TELEMETRY_QUEUE_URL` is not set, outside Lambda (local development, tests, and
`stralos-local`), or when Lambda refuses the registration. `finishTelemetry()` then awaits
the flush inside the request, capped by the remaining invocation budget.

Export is bounded and at-most-once: telemetry must not delay or retry an application
request indefinitely. Never capture bodies, headers, cookies, credentials, or arbitrary
context unless the bounded development/test payload mode explicitly permits the
redacted field.

## Redaction contract

The browser sanitiser removes common credential keys and token-shaped strings, strips
URL queries and fragments, replaces dynamic path segments with route placeholders,
and applies an allow-list to exception context.

The Global API applies string redaction to exported error names, messages, and stacks.
Its request-completion attributes come from a small set of validated or generated
fields; they do not pass through a general object redactor. Keep that set bounded and
never add request bodies, headers, cookies, or arbitrary context.

Treat additions to the capture schema as security-sensitive. Add only stable,
low-cardinality fields needed to diagnose or measure the product.

## Releases and source maps

Every browser event and API log carries a stable service name, deployment environment,
and immutable release version.

The shell registers `app_version` and `environment` as PostHog super properties when it
initialises, so shell and micro-frontend events carry the active release context.

Remote feature builds use `sourcemap: "hidden"` in `vite.config.ts`. The Vite build
emits `.map` files alongside the compiled output but does not inline them or reference
them from the served JS, so they are never exposed to browsers.

On a push to `main`, the build workflow runs `@posthog/cli@0.14.0 sourcemap inject --release-mode event` with the build source SHA before archiving the bundle. This annotates JavaScript with PostHog chunk identifiers that bind stack frames to their maps. The maps remain inside the by-SHA archive at this stage; the build does not upload them.

On pull request and other non-main builds, the maps are deleted from the build output before the artifact is uploaded. Those builds do not deploy and need no map artefacts.

During the central release (`release-create.yml`), the `source-maps` job uploads the verified maps to the environment's PostHog project for both test and prod. It uses `stralos-core/frontend-source-maps.yml` with `cli_version: 0.14.0` and `release_mode: event`, matching the injection step above. The maps are sourced from dev's archived bundle of the release commit — the same bytes test and prod serve — so the injected chunk identifiers resolve correctly in both environments. The upload is advisory: a failure is recorded in the release summary but does not fail the create step.

The serving copy excludes map files. Symbolication uses the injected source-SHA and chunk identity rather than the component’s semantic version. Source maps are never publicly served.

## Test environment checklist

1. Provision the Test token through Global's inline Terraform value or SSM fallback, or through the direct Terraform variable used by the other covered APIs.
2. Set the Test frontend runtime configuration with the correct hostname and release.
3. Enable backend logs and errors in Test.
4. Set Test session replay sampling to 100% in PostHog.
5. Exercise login, logout, organisation switching, shell navigation, a remote route,
   a handled React failure, and an API 5xx.
6. Confirm one replay session spans shell and remote navigation.
7. Confirm pageviews contain no query strings.
8. Confirm browser errors and API logs share release and environment values.
9. Confirm API logs use route templates and contain no bodies, headers, or credentials.
10. Confirm invalid runtime configuration and local defaults emit no telemetry.

Apply the same configuration and redaction checks to every covered backend service.
Create the Production project and configuration before enabling production capture.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.