Skip to main content

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. 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: 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 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:
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:
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:
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.