Observability and analytics

What exists, and what does not

Channel Status Detail

Segment (analytics.js)

Live

identify on login, track for events and errors. Key env.frontendSegmentIOCode.

Google Analytics (classic)

Live

Snippet injected under the global name fga. No page/event calls in application code — the snippet’s own auto-tracking only.

Event logger (track)

Live, narrow

One purpose: the KYC secure-site guardrail trail. See the guardrail trail.

Redux DevTools

Live in all builds

StoreDevtoolsModule.instrument() is unconditional.

Request ring buffer

Live

Last 50 requests in the errors state slice.

Version reporting

Live

Front-end and back-end versions in the sidebar and on the login screen.

VPN check

Flag-gated

CHECK_VPN.

Sentry

Not wired up

sentryUrl exists in every environment and is identical across templates. No SDK is installed and no code reads it. AppErrorHandler nonetheless tells users to "check sentry logs".

There is no error-tracking service. Production errors reach only the browser console and, if uncaught, a Segment WINDOW ERROR event. Do not assume a stack trace exists somewhere. See error handling.

MonitorService

src/app/shared/monitor.service.ts is the analytics entry point. It does four things.

1. Bootstraps Segment

It inlines the standard Segment snippet by hand — building the analytics stub array, the method list, the factory, and finally:

analytics.load(this.segmentCode);        // env.frontendSegmentIOCode
analytics.page();

The script URL is https://cdn.segment.com/analytics.js/v1/<code>analytics.min.js. Note the write key in the environment files ends with a / ('…zxA3/'), and set-env.ts appends another / — the concatenation is deliberate.

2. Bootstraps Google Analytics

The classic GA snippet, assigned to the global fga rather than ga. Nothing in the application calls it; it is present for whatever GA’s own auto-collection provides.

3. Identifies the user

initFullStory(): void {
  this.store.select(selectLoggedUserModel)
    .pipe(filter((u) => !!u))
    .subscribe((loggedUser: UserModel) => {
      window.analytics.identify(loggedUser.name, {
        email: loggedUser.email,
        displayName: loggedUser.name,
      });
    });
}

The method is named for FullStory but calls Segment. Note the identity passed is loggedUser.name (a display name) rather than a stable id such as loginKey — so Segment identities are not stable across a name change.

4. Maintains a message queue

send(message) → processQueue() → processMessage() → sendSegmentEvent(). The queue and serviceBusy flag exist for a series-of-actions design that was never finished — the commented-out EVENTS[message.subject].actions block with an Async.eachLimit loop is still in the source, attributed in a comment.

sendSegmentEvent flattens nested data into dash-joined keys (flatten(source, dest, '-', path)) and attaches screen dimensions as context:

window.analytics.track(message.subject, this.flatten(onboardingData), {
  context: { screen: { width: screen.width, height: screen.height } },
});

The KYC guardrail audit trail

The one genuinely load-bearing piece of telemetry. MonitorService.send() only logs for users holding a restricted permission:

public send(message?): void {
  if (![USER_PERMISSION.REST_UW_ACCESS, USER_PERMISSION.ADVANCE_AUDITORS]
        .includes(this.userPerrmision as USER_PERMISSION) || this.tracked) {
    return;
  }
  this.logEvent(message);
  this.tracked = !this.tracked;
}

logEvent sends both a Segment identify and an HTTP POST to ${eventLoggerUrl}track with:

body:    { user, url: 'test`', target_type: 'supplier',
           subject: 'Restricted UW Access',
           extra: { route, eventTypeCode: 'kyc_secure_site_guardrails', suppKey? } }
headers: Authorization: 'Token 79e4933035c840438f999f8ad9e38571'
         X-EventLogger-InstanceId: '8003'
         X-EventLogger-Type: 'analitics'
         X-EventLogger-TargetUUID: <suppKey>       (when present)

So access to a supplier by a REST_UW_ACCESS or ADVANCE_AUDITORS user is recorded as a kyc_secure_site_guardrails event against that supplier. That is the compliance purpose of those two permissions' logging behaviour — see authorisation.

Several problems are visible in this method, all known debt:

  • Authorization: Token <literal> — a hardcoded credential in source. The only one in the codebase.

  • X-EventLogger-InstanceId: '8003' is hardcoded, while every other consumer uses env.instanceId (8001).

  • data.url is the literal string 'test’ — a placeholder with a stray backtick. The real route is in extra.route.

  • this.userPerrmision (sic) is set from data.user.perms[0].permCode — only the first permission. A restricted user whose restricted code is not first is not logged.

  • tracked is a one-shot toggle, so at most one event is logged until setUntracked() is called.

  • The POST subscribes with two empty handlers, so a failure to record the audit event is silently swallowed.

  • X-EventLogger-Type: 'analitics' is misspelled — and matches the misspelling in eventTypes. Keep it.

Treat the guardrail trail as best-effort, not as a reliable audit log.

window.onerror

src/main.ts installs a last-resort handler outside Angular:

window.onerror = function (msg, url, lineNo, columnNo, error) {
  try {
    window.analytics.track('WINDOW ERROR', { msg, url, lineNo, columnNo, error });
  } catch (err) {}
  return false;
};

Returning false lets the default handler also run. The try/catch means a missing analytics object cannot break it. This is the only automatic error reporting in production.

ErrorLogger

shared/services/error-logger/error-logger.service.ts sends a Segment event with an optional store dump, redacting six slices (auth, backgroundReports, calc, crm, layout, utils).

What survives redaction includes supplier — which holds tax ids, owner SSNs and addresses for every supplier visited — and errors, which holds the last 50 serialised requests.

Pass withStoreDump = false unless you have a specific reason. See error handling for the full analysis.

Version reporting

shared/version.service.ts:

getFrontendVersion(): string          // environment.VERSION — from package.json at build
getBackendVersion(): Observable<string> // GET <internalApiUrl>version, shareReplay(1)

Surfaced in two places:

  • The sidebar footer — F: <feVersion> and B: <beVersion> (data-test="sidebarFeVersion" / sidebarBeVersion).

  • The login screen, before authentication.

semver is a dependency for comparing the two, and environment also carries gitHash (the full 40-character commit) and lastBuildDate (ISO to the minute), both stamped by deployment/set-env.ts. See environments.

The stage banner

shared/layout/app-stage-info/ is designed to show which environment and backend you are pointed at, including a truncated git hash (HASH_LENGTH_TO_SHOW = 8) and the build date, and only on envName === 'test'.

It is currently disabled: getStageName() returns of('') with the real implementation commented out. The helper methods (getAppBaseUrl, getConnectedBackend, getGitHash, getBuildDate, removeConstantPartsFromUrl) are all still there. Re-enabling it is a one-line change — useful when you need to know which backend a deployed environment is talking to.

The component uses ViewEncapsulation.None, so its styles are global.

The VPN check

shared/vpn-tester.service.ts, started by AppComponent when CHECK_VPN is enabled:

private readonly DELAY = 3000;      // first check after 3 s
private readonly INTERVAL = 30000;  // then every 30 s

timer(DELAY, INTERVAL).pipe(
  mergeMap(() => this.http.options(`${environment.pybDocumentsApiUrl}health-check`)
    .pipe(mapTo(true), catchError(() => of(false)))),
).subscribe((hasVpn) => {
  if (hasVpn) return;
  this.toastrService.error(
    'Please turn on the Payability VPN. Without it you may experience functionality issues');
});

It probes an internal host with an OPTIONS request every 30 seconds and toasts on failure. Purely advisory — nothing is blocked. The real network restriction is enforced upstream.

The subscription is never torn down, and it toasts on every failed check — so an operator genuinely off-VPN accumulates a toast every 30 seconds. Also note AppComponent calls initVpnTester() from its constructor, before flags are initialised by the route guard, so on a cold load the flag check may read false and the tester never starts. See feature flags.

The Logger helper

src/app/shared/logger.ts — static, isDevMode()-gated console output with distinctive group headers (------- SILENT LOG DEBUG --------, !!!!!!! SILENT LOG ERROR !!!!!!!). Silent in production. Used by AuthEffects, SupplierEffects and SseService.

Developer-facing observability

Tool Detail

Redux DevTools

Instrumented unconditionally — including production builds. The fastest way to see what the app is doing.

Request ring buffer

Last 50 requests (serialised, bodies included) in state.errors.cachedRequests.

Feature-flag console dump

FeatureFlagsGeneralGuard prints the resolved flag table once per page load in non-production. See DEV Admin.

Iframe message log

CommunicationService logs every [IA] Sending / [IA] Received in non-production. See iframe messaging.

Store debugger

src/app/store-debugger.ts — requires editing EnableStoreDebugging = false in source. Prefer DevTools.

DevTools instrumentation plus the request ring buffer means a production build exposes the full store — including supplier PII and recent request bodies — to anyone with the browser extension. Accepted for an internal tool; worth knowing.

Adding telemetry

  • Business event → MonitorService.send({ subject, data }). Remember it only fires for restricted users; if you need it for everyone, call window.analytics.track through a new method rather than reusing send.

  • Diagnostic detail → Logger.log (dev-only).

  • Error context → ErrorLogger.log(title, data, false).

  • Never add a hardcoded credential. If a new endpoint needs auth, it goes through AuthInterceptor like everything else — see HTTP layer.