Service coverage
Stralos uses PostHog for product analytics, session replay, browser error tracking, and structured application logs:stralos-shellowns the browser SDK for the shell and all micro-frontends.stralos-uiexposes an error-boundary reporting hook without depending on PostHog.stralos-api-globalemits server exceptions and structured HTTP completion logs.stralos-api-activities,stralos-api-assistant,stralos-api-documents,stralos-api-estimate,stralos-api-rates, andstralos-api-scheduleruse the shared API observability plugin for bounded request events and server exceptions.stralos-feature-dashboardis covered by the shell. It must not initialise another browser SDK.
Project and environment model
Use one PostHog application project per deployment environment, not one project per micro-frontend or API.
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
Browser ownership
The shell initialisesposthog-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
$pageviewevents after router navigation - CLS, FCP, INP, LCP, and TTFB as
web_vitals - global
window.errorandunhandledrejectionfailures - React error-boundary failures, including the original Module Federation cause
- the verified user as the PostHog distinct ID, with
name,email, androleas person properties - the verified organisation through the
organisationgroup, with the organisation name as a group property
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 towindow.__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:
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 witherrorsLoading 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 throughuseFeatureFlag 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 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 fromstralos-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
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 bycreateReplayMasker 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 markeddata-ph-maskis 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 injectedwindow.__STRALOS_OBSERVABILITY__ value or /runtime-config.json:
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:
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 registersapp_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
- 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.
- Set the Test frontend runtime configuration with the correct hostname and release.
- Enable backend logs and errors in Test.
- Set Test session replay sampling to 100% in PostHog.
- Exercise login, logout, organisation switching, shell navigation, a remote route, a handled React failure, and an API 5xx.
- Confirm one replay session spans shell and remote navigation.
- Confirm pageviews contain no query strings.
- Confirm browser errors and API logs share release and environment values.
- Confirm API logs use route templates and contain no bodies, headers, or credentials.
- Confirm invalid runtime configuration and local defaults emit no telemetry.

