Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions charts/Feature.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,18 @@ values:
displayName: Number of replicas
config:
type: int
tracking.enabled:
displayName: Enable analytics tracking
description: Enable Umami/Innblikk tracking via the ResearchOps sporing script
config:
type: bool
tracking.websiteId:
displayName: Tracking website ID
description: The Umami/Innblikk website UUID for this tenant
config:
type: string
tracking.dev:
displayName: Use dev tracking proxy
description: Use the dev sporing script and proxy endpoint instead of production
config:
type: bool
6 changes: 6 additions & 0 deletions charts/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ spec:
value: '{{ .Values.fasit.tenant.name }}'
- name: 'GITHUB_ORGANIZATION'
value: '{{ .Values.gitHub.organization }}'
- name: 'TRACKING_ENABLED'
value: '{{ .Values.tracking.enabled }}'
- name: 'TRACKING_WEBSITE_ID'
value: '{{ .Values.tracking.websiteId }}'
- name: 'TRACKING_DEV'
value: '{{ .Values.tracking.dev }}'
# - name: 'OTEL_TRACES_EXPORTER'
# value: 'otlp'
# - name: 'OTEL_METRICS_EXPORTER'
Expand Down
5 changes: 5 additions & 0 deletions charts/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ resources:
memory: 512Mi

replicaCount: 2

tracking:
enabled: false
websiteId: ""
dev: false
70 changes: 70 additions & 0 deletions docs/umami-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Plan: Integrate Umami/Sporing Analytics

## Summary

Add NAV's ResearchOps "sporing" tracking script to console-frontend. This provides privacy-first analytics (no cookies, GDPR compliant) with automatic page view tracking and custom event tracking for key user actions. Data flows through NAV's event proxy to BigQuery.

## Prerequisites

1. Request website registration in Slack `#researchops` — provide: app name ("Console"), domain, team name
2. Get tracking codes from `innblikk.ansatt.nav.no/sporingskoder` (prod) and `innblikk.ansatt.dev.nav.no/sporingskoder` (dev)

## Steps

### Phase 1: Chart & Server Configuration

1. **Add tracking flag to Helm chart** — New values in `charts/Feature.yaml` and `charts/values.yaml`:
- `tracking.enabled` (boolean, default `false`) — opt-in per tenant
- `tracking.websiteId` (string) — the Umami/Innblikk website UUID
- Add env vars `TRACKING_ENABLED` and `TRACKING_WEBSITE_ID` to `charts/templates/deployment.yaml`

2. **Pass tracking config to client via layout** — In `src/hooks.server.ts`, read `TRACKING_ENABLED` and `TRACKING_WEBSITE_ID` from env, expose via `event.locals`. In `src/routes/+layout.server.ts`, pass to page data. This follows the existing pattern for `tenantName` and `githubOrganization`.

### Phase 2: Script Integration

3. **Conditionally inject tracking script** — In `src/routes/+layout.svelte` `onMount`, dynamically create and append the `<script>` tag when `data.trackingEnabled` is true. Uses `sporing-dev.js` when `data.trackingDev` is true, otherwise production `sporing.js`.

4. **Add `beforeSend` function** — Assigned to `window.beforeSend` in `onMount`. Replaces resolved URLs with SvelteKit route IDs (e.g. `/team/[team]/secrets`) for aggregate page analytics. Route ID is kept in sync via `afterNavigate`.
Comment thread
rbjornstad marked this conversation as resolved.

5. **Add TypeScript type declarations in `src/app.d.ts`** — Declare `window.sporing` with `track()` and `identify()` methods so custom event calls are type-safe. Update `App.Locals` and layout data types for tracking config.

### Phase 3: Custom Event Tracking

6. **Create a tracking utility module `src/lib/tracking.ts`** — Thin wrapper around `window.sporing.track()` that:
- Checks if `window.sporing` exists (guards against script not loaded, local dev, tests)
- Exposes typed `trackEvent(name: string, data?: Record<string, string | number | boolean>)` function
- No-ops gracefully if tracking unavailable

7. **Add event tracking to key user actions** — Import and call `trackEvent()` after successful actions. Page context (route ID) is automatically included via `beforeSend`. Start with Tier 1 (highest insight value):
- **Favourites**: `favorite-add`, `favorite-remove`, `favorite-reorder`, `favorite-click` — in `src/lib/ui/AddToFavorites.svelte`, `src/lib/domain/list-items/FavoritesListItem.svelte`, `src/routes/FavoritesList.svelte`
- **Team lifecycle**: `delete-team` — in `src/routes/team/[team]/settings/confirm_delete/+page.svelte`
- **App lifecycle**: `restart-app`, `stop-app`, `delete-app` — in `src/routes/team/[team]/[env]/app/[app]/AppActions.svelte`, `src/routes/team/[team]/[env]/app/[app]/delete/+page.svelte`
- **Secret access**: `reveal-secret` — in `src/routes/team/[team]/[env]/secret/[secret]/ViewSecretModal.svelte`
- **Job triggers**: `trigger-job` — in `src/routes/team/[team]/[env]/job/[job]/JobActions.svelte`
- **Vulnerability mgmt**: `suppress-vulnerability` — in `src/lib/domain/vulnerability/SuppressFinding.svelte`

Events carry only the event name. No custom payload data, no user identifiers, no resource names. Page context (route ID pattern) is attached automatically by `beforeSend`.

### Phase 4: Development & Testing Setup

8. **Dev script variant** — In `onMount`, conditionally load `sporing-dev.js` (proxy: `reops-event-proxy.ekstern.dev.nav.no`) for non-production. Controlled by `TRACKING_DEV` env var.

9. **Disable tracking in tests** — Set `localStorage.setItem('sporing.disabled', '1')` in E2E/Playwright setup.

## Decisions

- **Multi-tenant**: Tracking is opt-in per tenant via Helm chart flag (`tracking.enabled`). Disabled by default — only enabled for NAV where no consent is required
- **No consent UI**: Console for NAV is an internal ansatte tool — cookie consent not required per ResearchOps guidance
- **No user identification**: Fully anonymous tracking, no `sporing.identify()` calls
- **Route IDs sent instead of resolved URLs**: `beforeSend` replaces the page URL with the SvelteKit route ID (e.g., `/team/[team]/[env]/app/[app]`), so no team/app/env slugs are ever transmitted. Events are dropped if no route ID is available.
- **Query params never sent**: Since only route IDs are transmitted, query parameters are inherently excluded
- **No CSP changes needed currently**: No CSP headers are configured; if CSP is added later, allowlist `cdn.nav.no` (script-src) and `reops-event-proxy.nav.no` + `reops-event-proxy.ekstern.dev.nav.no` (connect-src)
- **Server-side mutations**: For mutations in `+page.server.ts` (form actions), tracking must happen client-side after the form action succeeds

## References

- [ResearchOps sporing docs](https://reops-docs.ansatt.dev.nav.no/innsamling/sporingsskript/)
- [Getting started guide](https://reops-docs.ansatt.dev.nav.no/guider/kom-i-gang-med-sporing/)
- Dashboard: `innblikk.ansatt.nav.no` (prod) / `innblikk.ansatt.dev.nav.no` (dev)
- BigQuery: `team-researchops-prod-01d6.umami_views.event`
- Slack: `#researchops`
31 changes: 31 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,44 @@ import type { TagProps } from '@nais/ds-svelte-community/components/Tag/type.js'

// for information about these interfaces
declare global {
interface SporingPayload {
hostname?: string;
language?: string;
referrer?: string;
screen?: string;
title?: string;
url?: string;
website?: string;
name?: string;
data?: Record<string, string | number | boolean>;
}

interface Sporing {
track(): void;
track(eventName: string): void;
track(eventName: string, data: Record<string, string | number | boolean>): void;
track(payload: SporingPayload): void;
track(callback: (payload: SporingPayload) => SporingPayload): void;
identify(uniqueId: string): void;
identify(uniqueId: string, data: Record<string, string | number | boolean>): void;
}

interface Window {
sporing?: Sporing;
beforeSend?: (type: string, payload: SporingPayload) => SporingPayload | null;
__sporingRouteId?: string;
}

namespace App {
interface Error {
docPath?: string;
}
interface Locals {
tenantName: string;
githubOrganization: string;
trackingEnabled: boolean;
trackingWebsiteId: string;
trackingDev: boolean;
}
interface PageData {
meta: {
Expand Down
3 changes: 3 additions & 0 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const handle: Handle = async ({ event, resolve }) => {

event.locals.tenantName = env.TENANT_NAME || '';
event.locals.githubOrganization = env.GITHUB_ORGANIZATION || '';
event.locals.trackingEnabled = env.TRACKING_ENABLED === 'true';
event.locals.trackingWebsiteId = env.TRACKING_WEBSITE_ID || '';
event.locals.trackingDev = env.TRACKING_DEV === 'true';

const response = await resolve(event, {
filterSerializedResponseHeaders: () => true
Expand Down
14 changes: 13 additions & 1 deletion src/lib/domain/list-items/FavoritesListItem.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { favorites } from '$lib/stores/favorites.svelte';
import { trackEvent } from '$lib/tracking';
import IconLabel from '$lib/ui/IconLabel.svelte';
import ListItem from '$lib/ui/ListItem.svelte';
import { Button, Tooltip } from '@nais/ds-svelte-community';
Expand All @@ -13,6 +14,11 @@

function removeFavorite() {
favorites.removeFavorite(path);
trackEvent('favorite-remove');
}

function handleClick() {
trackEvent('favorite-click');
}

function capitalize(str: string): string {
Expand Down Expand Up @@ -161,7 +167,13 @@

<ListItem>
<div class="row">
<IconLabel label={pathToFavoriteLabel(path)} icon={StarFillIcon} size="medium" href={path} />
<IconLabel
label={pathToFavoriteLabel(path)}
icon={StarFillIcon}
size="medium"
href={path}
onclick={handleClick}
/>
<div class="actions">
<Tooltip placement="bottom" content="Remove from favorites">
<Button
Expand Down
2 changes: 2 additions & 0 deletions src/lib/domain/vulnerability/SuppressFinding.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import type { ImageVulnerabilitySeverity } from '$houdini/graphql/enums';
import WorkloadLink from '$lib/domain/workload/WorkloadLink.svelte';
import ExternalLink from '$lib/ui/ExternalLink.svelte';
import { trackEvent } from '$lib/tracking';
import { suppressionStateOptions as SUPPRESS_OPTIONS } from '$lib/utils/vulnerabilities';
import {
Alert,
Expand Down Expand Up @@ -130,6 +131,7 @@
return;
}

trackEvent('suppress-vulnerability');
errormessage = '';
const vulnerabilityReportUrl =
'/team/' + team + '/' + env + '/' + workload + '/vulnerabilities';
Expand Down
10 changes: 10 additions & 0 deletions src/lib/tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { browser } from '$app/environment';

export function trackEvent(name: string, data?: Record<string, string | number | boolean>): void {
if (!browser || !window.sporing) return;
if (data) {
window.sporing.track(name, data);
} else {
window.sporing.track(name);
}
}
Comment thread
rbjornstad marked this conversation as resolved.
Comment thread
rbjornstad marked this conversation as resolved.
3 changes: 3 additions & 0 deletions src/lib/ui/AddToFavorites.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { favorites } from '$lib/stores/favorites.svelte';
import { trackEvent } from '$lib/tracking';
import { Button, Tooltip } from '@nais/ds-svelte-community';
import { StarFillIcon, StarIcon } from '@nais/ds-svelte-community/icons';

Expand All @@ -12,8 +13,10 @@
function toggleFavorite() {
if (favorites.isFavorite(path)) {
favorites.removeFavorite(path);
trackEvent('favorite-remove');
} else {
favorites.addFavorite(path);
trackEvent('favorite-add');
}
}
Comment thread
rbjornstad marked this conversation as resolved.

Expand Down
5 changes: 4 additions & 1 deletion src/routes/+layout.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export async function load(event) {
tenantName: event.locals.tenantName,
githubOrganization: event.locals.githubOrganization,
theme: theme as 'dark' | 'light',
userAgent: event.request.headers.get('user-agent') || 'unknown'
userAgent: event.request.headers.get('user-agent') || 'unknown',
trackingEnabled: event.locals.trackingEnabled,
trackingWebsiteId: event.locals.trackingWebsiteId,
trackingDev: event.locals.trackingDev
};
}
22 changes: 22 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@
let refreshCookieInterval: ReturnType<typeof setInterval> | undefined;

onMount(() => {
if (data.trackingEnabled && data.trackingWebsiteId) {
window.__sporingRouteId = page.route.id ?? undefined;

window.beforeSend = (_type, payload) => {
const routeId = window.__sporingRouteId;
if (routeId) {
return { ...payload, url: routeId };
}
return null;
};
const script = document.createElement('script');
script.defer = true;
script.src = data.trackingDev
? 'https://cdn.nav.no/team-researchops/sporing/sporing-dev.js'
: 'https://cdn.nav.no/team-researchops/sporing/sporing.js';
script.setAttribute('data-website-id', data.trackingWebsiteId);
script.setAttribute('data-before-send', 'beforeSend');
script.setAttribute('data-tag', 'console');
document.head.appendChild(script);
}

refreshCookieInterval = setInterval(
async () => {
if (user?.__typename !== 'User') return;
Expand All @@ -72,6 +93,7 @@

afterNavigate(() => {
loading = false;
window.__sporingRouteId = page.route.id ?? undefined;
});

const title = $derived.by(() => {
Expand Down
6 changes: 5 additions & 1 deletion src/routes/FavoritesList.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import FavoritesListItem from '$lib/domain/list-items/FavoritesListItem.svelte';
import { favorites } from '$lib/stores/favorites.svelte';
import { trackEvent } from '$lib/tracking';
import SortableList from '$lib/ui/SortableList.svelte';
import { BodyLong, Heading } from '@nais/ds-svelte-community';
import { StarIcon } from '@nais/ds-svelte-community/icons';
Expand All @@ -13,7 +14,10 @@

<SortableList
items={favorites.getFavorites()}
onReorder={(newOrder) => favorites.setFavorites(newOrder)}
onReorder={(newOrder) => {
favorites.setFavorites(newOrder);
trackEvent('favorite-reorder');
}}
>
{#each favorites.getFavorites().filter(Boolean) as fav (fav)}
<FavoritesListItem path={fav} />
Expand Down
3 changes: 3 additions & 0 deletions src/routes/team/[team]/[env]/app/[app]/AppActions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
StopIcon,
TrashIcon
} from '@nais/ds-svelte-community/icons';
import { trackEvent } from '$lib/tracking';

interface Props {
viewerIsMember: boolean;
Expand Down Expand Up @@ -85,6 +86,7 @@
if (result.errors) {
return { ok: false, message: result.errors.map((e) => e.message).join(', ') };
}
trackEvent('restart-app');
return { ok: true, message: 'Successfully restarted application.' };
};

Expand All @@ -96,6 +98,7 @@
if (result.errors) {
return { ok: false, message: result.errors.map((e) => e.message).join(', ') };
}
trackEvent('stop-app');
return {
ok: true,
message:
Expand Down
2 changes: 2 additions & 0 deletions src/routes/team/[team]/[env]/app/[app]/delete/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import WarningIcon from '$lib/icons/WarningIcon.svelte';
import GraphErrors from '$lib/ui/GraphErrors.svelte';
import Time from '$lib/ui/Time.svelte';
import { trackEvent } from '$lib/tracking';
import { BodyShort, Button, Heading, TextField } from '@nais/ds-svelte-community';
import { get } from 'svelte/store';
import type { PageProps } from './$types';
Expand Down Expand Up @@ -38,6 +39,7 @@
});

if (resp.data?.deleteApplication.success) {
trackEvent('delete-app');
goto(`/team/${app.team.slug}?deleted=app/${app.name}`);
}
};
Expand Down
2 changes: 2 additions & 0 deletions src/routes/team/[team]/[env]/job/[job]/JobActions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
PlayIcon,
TrashIcon
} from '@nais/ds-svelte-community/icons';
import { trackEvent } from '$lib/tracking';

interface Props {
viewerIsMember: boolean;
Expand Down Expand Up @@ -94,6 +95,7 @@
if (result.errors) {
return { ok: false, message: result.errors.map((e) => e.message).join(', ') };
}
trackEvent('trigger-job');
return { ok: true, message: `Successfully triggered run "${runName}".` };
};
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { PadlockLockedIcon } from '@nais/ds-svelte-community/icons';

import type { ValueEncoding$options } from '$houdini';
import { trackEvent } from '$lib/tracking';

interface SecretValue {
name: string;
Expand Down Expand Up @@ -55,6 +56,7 @@
isSubmitting = false;

if (!$revealSecrets.errors && $revealSecrets.data?.viewSecretValues?.values) {
trackEvent('reveal-secret');
onSuccess($revealSecrets.data.viewSecretValues.values);
handleClose();
}
Expand Down
Loading