Skip to content
Closed
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
27 changes: 27 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,39 @@ Imports point one way only:
up into a component to get them.
- `src/lib/stores/*` must never import from `src/lib/components/*`. The persisted shape of a query
(`QueryDraft`) is domain, not view.
- `src/lib/telemetry/*` sits beside `stores/` and reads `stores/settings`. Therefore
`stores/settings.ts` must never import telemetry. `watchSettings` in `telemetry/index.ts` diffs the
store and reports a change from there.
- `src/lib/components/*` holds `.svelte` files, plus the `.svelte.ts` runes classes and the barrel
`index.ts` files that belong to one component folder. Anything with no Svelte dependency and more
than one consumer belongs below, in `query/` or `geo/`.
- New non-component files under `src/lib/query/*` and `src/lib/geo/*` use kebab-case, matching
`stores/` and `beacon-api/`. Components keep PascalCase.

## Telemetry (Important)
Read `src/lib/telemetry/README.md` before you add an event.

- Event names are a **closed list on both sides**: `ActionName` in `telemetry/types.ts`, and
`TelemetryValidator::NAMES` on `beacon-datalake.org`. The server drops an unknown name with no
error and no log line. Land the server list first, then Studio.
- **The `studio_telemetry` table takes no new columns**, unless absolutely required. Discuss that with
the user first. Every new field goes in the `props` JSON object. A new column needs a change in
three places plus a migration, and that is a separate job.
- The server **drops a whole `props` object** above its cap; it does not cut it. `track` routes every
object through `fitProps`, which degrades in steps. Never build an event that bypasses `track`.
- Add `describeQuery(query)` to the props of every query event. Query content is open data
(ERA5, WOD), so the filter values go out as well, and the time range is the most used field.

### The two files outside this repo
| Part | Path |
|---|---|
| Receiver (the endpoint that Studio posts to) | `S:\www\beacon-datalake.org\src\Controller\Api\TelemetryController.php` |
| Dashboard (the page that reads the events) | `S:\application\beacon-datalake.org\management\src\Controller\StudioTelemetryController.php` |

Edit either file **only when the task needs it**. Both live in another repository, on a slow share.
**Always tell the user which of the two you changed.** The user deploys them by hand. Without that
message the change never reaches production.

## Frontend Conventions
- Prefer existing UI primitives from `src/lib/components/ui/*`.
- Prefer PascalCase naming for components, types, and other applicable identifiers.
Expand Down
10 changes: 9 additions & 1 deletion src/lib/components/home/QuickStartExamples.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import Card from '@/components/card/Card.svelte';
import { Badge } from '@/components/ui/badge/index.js';
import { homeExamples, type HomeExample } from '@/data/home-examples';
import { track } from '@/telemetry';
import { SHARE_LINK_PATH } from '@/stores/stored-query';

const METRIC_HINT = 'Measured on a reference run. Your run can differ.';
Expand Down Expand Up @@ -61,7 +62,14 @@
</div>

<div class="actions">
<Button variant="outline" href={exampleHref(example)}>
<Button
variant="outline"
href={exampleHref(example)}
onclick={() =>
track('example.start', {
props: { title: example.title, table: example.tableName, source: example.sourceName }
})}
>
<BracesIcon />
Check query
</Button>
Expand Down
28 changes: 22 additions & 6 deletions src/lib/components/plots/ChartExplorerController.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { buildContours, type ContourResult } from '@/plots/contour';
import { buildInterpolationSurface, type InterpolationResult } from '@/plots/interpolation';
import { samplePlotSeries } from '@/plots/sampling';
import { getSettings } from '@/stores/settings';
import { track } from '@/telemetry';
import { describeQuery, track } from '@/telemetry';

export class ChartExplorerController {
/** The raw query result of the active block. */
Expand Down Expand Up @@ -411,6 +411,24 @@ export class ChartExplorerController {
// ------------------------------------------------------------- query cycle

/** Run a query and show it. */
/**
* Reports one chart view. The call comes after the plot setup, so `renderMs`
* holds the time that the chart itself took.
*/
private reportVisualise(query: CompiledQuery, node: BeaconNode, readyAt: number): void {
track('query.visualise', {
nodeHost: node.url,
rowCount: this.entry?.rowCount,
queryId: this.entry?.queryId,
props: {
...describeQuery(query),
kind: 'chart',
tier: this.entry?.stats?.tier,
renderMs: Math.round(performance.now() - readyAt)
}
});
}

async runAndShowQuery(query: CompiledQuery, node: BeaconNode, blockId: string): Promise<void> {
const token = this.beginRun(blockId);
this.latestRun = token;
Expand All @@ -421,18 +439,16 @@ export class ChartExplorerController {
this.markRun(blockId, this.entry.rowCount);
this.isLoading = false;

track('query.visualise', {
nodeHost: node.url,
rowCount: this.entry.rowCount,
props: { kind: 'chart' }
});
const readyAt = performance.now();

if (this.entry.rowCount === 0) {
this.reportVisualise(query, node, readyAt);
addToast({ type: 'info', message: 'Query executed successfully but returned no data.' });
return;
}

this.syncPlotToColumns();
this.reportVisualise(query, node, readyAt);
} catch (error) {
this.endRun(blockId, token);
if (this.latestRun === token) this.isLoading = false;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/components/query-builder/AddFilterDropdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type { DataType } from '@/beacon-api/types';
import { Utils } from '@/utils';
import type { SelectedFilterType } from '@/query/filter-types';
import { track } from '@/telemetry';

let {
data_type,
Expand Down Expand Up @@ -166,6 +167,9 @@
value={filter.label}
onSelect={() => {
selected_filters.push(filter);
track('builder.filter.add', {
props: { kind: filter.filter_value.type, dataType: typeof data_type === 'string' ? data_type : 'Timestamp' }
});
open = false;
}}
>
Expand Down
12 changes: 12 additions & 0 deletions src/lib/components/query-builder/QueryBuilder.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import type { BeaconNode } from '@/beacon-api/types';
import { BeaconClient } from '@/beacon-api/client';
import { track } from '@/telemetry';
import QueryBuilderNodeSelector from './QueryBuilderNodeSelector.svelte';
import QueryBuilderParameterBlock from './QueryBuilderParameterBlock.svelte';
import QueryBuilderOutputFormatSelector from './QueryBuilderOutputFormatSelector.svelte';
Expand Down Expand Up @@ -202,9 +203,20 @@
return pendingSeed;
});

/** The table of the last reported choice. It stops a repeat on every re-render. */
let reportedTable = '';

$effect(() => {
status.dataTable = selected_table_name;
onTableChange?.(selected_table_name);

if (selected_table_name && selected_table_name !== reportedTable) {
reportedTable = selected_table_name;
track('builder.table.select', {
nodeHost: node?.url,
props: { table: selected_table_name, tables: table_names.length }
});
}
});

</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import Parameter from './Parameter.svelte';
import type { SelectedFilterType } from '@/query/filter-types';
import { addToast } from '@/stores/toasts';
import { track } from '@/telemetry';
import type { QuerySelectionStatus } from '@/query/selection-status';
import type { QueryActions } from './QueryActions';
import { compileDraft, defaultOutputFormat, type QueryDraft } from '@/query/draft';
Expand Down Expand Up @@ -323,6 +324,14 @@
type: fields[index].type,
selected_filters: []
});

track('builder.column.add', {
props: {
column: fields[index].name,
dataType: typeof fields[index].type === 'string' ? fields[index].type : 'Timestamp',
columns: selectedFields.length
}
});
} else {
selectedFields.splice(selectedIndex, 1);
}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/components/query-builder/QueryWorkspace.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { getCurrentNode, nodes, matchRef, resolveRef } from '@/services/beacon-n
import { openNodesSettled, whenOpenNodesSettled } from '@/services/open-nodes-import';
import type { BeaconNode } from '@/beacon-api/types';
import { addToast } from '@/stores/toasts';
import { track } from '@/telemetry';
import { makeEmptyQuerySelectionStatus, type QuerySelectionStatus } from '@/query/selection-status';
import { compileDraft, makeEmptyDraft, type QueryDraft } from '@/query/draft';
import { isPlotRenderable, type ChartViewState } from '@/plots/plot-config';
Expand Down Expand Up @@ -403,6 +404,7 @@ export class QueryWorkspace {
node: this.defaultNodeRef()
});
this.select(block.id);
track('workbench.block.add', { props: { origin: 'empty', blocks: this.blocks.length } });
return block;
}

Expand All @@ -418,6 +420,7 @@ export class QueryWorkspace {
});
queryBlocks.insertAt(this.blocks.length, block);
this.select(block.id);
track('workbench.block.add', { props: { origin: source.role, blocks: this.blocks.length } });
return block;
}

Expand All @@ -439,6 +442,7 @@ export class QueryWorkspace {
node: ref
});
this.select(block.id);
track('workbench.block.add', { props: { origin: 'query', blocks: this.blocks.length } });
return block;
}

Expand All @@ -453,6 +457,7 @@ export class QueryWorkspace {
});
queryBlocks.insertAt(index + 1, copy);
this.select(copy.id);
track('workbench.block.add', { props: { origin: 'duplicate', blocks: this.blocks.length } });
}

/** Give a block a new name. */
Expand Down
28 changes: 22 additions & 6 deletions src/lib/components/visualisation/MapViewController.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import type { BeaconNode, CompiledQuery, Select as QuerySelect } from '@/beacon-
import { ApacheArrowUtils } from '@/arrow-utils';
import { getSettings } from '@/stores/settings';
import { addToast } from '@/stores/toasts';
import { track } from '@/telemetry';
import { describeQuery, track } from '@/telemetry';
import { Utils } from '@/utils';
import type { Rendered } from '@/util-types';
import MapPopupContent from '@/components/MapPopupContent.svelte';
Expand Down Expand Up @@ -362,6 +362,24 @@ export class MapViewController {
* block, for example after the user applied an area filter. The camera then
* stays where the user left it.
*/
/**
* Reports one map view. The call comes after the render, so `renderMs` holds
* the time that the map itself took: the dedup pass and the geometry build.
*/
private reportVisualise(query: CompiledQuery, node: BeaconNode, readyAt: number): void {
track('query.visualise', {
nodeHost: node.url,
rowCount: this.entry?.rowCount,
queryId: this.entry?.queryId,
props: {
...describeQuery(query),
kind: 'map',
tier: this.entry?.stats?.tier,
renderMs: Math.round(performance.now() - readyAt)
}
});
}

async runAndShowQuery(query: CompiledQuery, node: BeaconNode, blockId: string, keepCamera: boolean): Promise<void> {
this.isLoading = true;
const token = this.beginRun(blockId);
Expand All @@ -373,19 +391,17 @@ export class MapViewController {
this.entry = await BeaconClient.ensureQuery(query, node, blockId);
this.markRun(blockId, this.entry.rowCount);

track('query.visualise', {
nodeHost: node.url,
rowCount: this.entry.rowCount,
props: { kind: 'map' }
});
const readyAt = performance.now();

if (this.entry.rowCount === 0) {
this.isLoading = false;
this.reportVisualise(query, node, readyAt);
addToast({ type: 'info', message: 'Query executed successfully but returned no data.' });
return;
}

await this.prepareTable(keepCamera);
this.reportVisualise(query, node, readyAt);
} catch (error) {
this.endRun(blockId, token);
if (this.latestRun === token) this.isLoading = false;
Expand Down
12 changes: 12 additions & 0 deletions src/lib/services/beacon-node-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import { get, readonly, writable, type Readable } from 'svelte/store';
import type { BeaconNodeHealth } from '@/beacon-api/types';
import { track } from '@/telemetry';
import { normalizeUrl } from './beacon-node-url';

export type { BeaconNodeHealth };
Expand Down Expand Up @@ -58,8 +59,19 @@ export function getHealthOf(url: string): BeaconNodeHealth {
/** Records the result of one check. This is the only writer of the store. */
export function setHealth(url: string, health: BeaconNodeHealth): void {
const key = normalizeUrl(url);
const previous = getHealthOf(url).status;

healthStore.update((map) => ({ ...map, [key]: health }));

// A steady result repeats every sweep. Only a change carries news.
if (previous !== health.status) {
track('node.health', {
level: health.status === 'offline' ? 'warn' : 'info',
nodeHost: url,
durationMs: health.latencyMs ?? undefined,
props: { from: previous, to: health.status }
});
}
}

/** Drops the health of one node. Call it when no node keeps that URL. */
Expand Down
18 changes: 17 additions & 1 deletion src/lib/services/beacon-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ export function addNode(input: BeaconNodeInput): BeaconNode {
selectedIdStore.set(stored.id);
}

track('node.add', { nodeHost: stored.url, props: { hasToken: stored.token !== '' } });

return { ...stored, ...UNKNOWN_HEALTH };
}

Expand All @@ -264,6 +266,14 @@ export function updateNode(id: string, input: Partial<BeaconNodeInput>): BeaconN
})
);

track('node.update', {
nodeHost: updated.url,
props: {
urlChanged: updated.url !== previous.url,
tokenChanged: updated.token !== previous.token
}
});

if (updated.token !== previous.token) {
dropHealth(updated.url);
return { ...updated, ...UNKNOWN_HEALTH };
Expand Down Expand Up @@ -295,6 +305,8 @@ export function removeNode(id: string): BeaconNode | null {
selectFirstIfNone();
}

track('node.remove', { nodeHost: removed.url, props: { wasSelected } });

return removed;
}

Expand All @@ -308,7 +320,11 @@ export function selectNode(id: string | null): void {
selectedIdStore.set(id);

const selected = id === null ? null : findById(id);
track('node.select', { nodeHost: selected?.url });

track('node.select', {
nodeHost: selected?.url,
props: { status: selected ? getHealthOf(selected.url).status : 'none' }
});
}

/**
Expand Down
Loading