Skip to content
Merged
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
48 changes: 46 additions & 2 deletions .github/workflows/publish-client.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ concurrency:
cancel-in-progress: false

permissions:
contents: read
# write, not read: the run creates the release for the tag and uploads the build to it.
contents: write
packages: write

defaults:
Expand Down Expand Up @@ -57,6 +58,12 @@ jobs:
echo "Tag $TAG does not match client/package.json version $PACKAGE_VERSION."
exit 1
fi
# The later steps name the artifact off the version this step just verified, rather than
# deriving it again.
{
echo "VERSION=${PACKAGE_VERSION}"
echo "TARBALL=eagle-analytics-client-${PACKAGE_VERSION}.tgz"
} >> "$GITHUB_ENV"

- name: Typecheck
run: yarn typecheck
Expand All @@ -74,8 +81,45 @@ jobs:
YARN_NPM_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: yarn npm publish

# `client/dist` is gitignored, so the tag on its own carries no build and a copy vendored into
# eagle-admin or eagle-public cannot be traced back to a release. The tarball is the release
# artifact; SHA256SUMS is what a consumer records next to its vendored copy.
#
# `yarn pack` rather than `npm pack`: same tarball, and `--out` names it without a rename step.
- name: Pack the release assets
run: |
set -euo pipefail
yarn pack --out "$TARBALL"
# dist paths are relative to client/, which is also the tarball's `package/` root, so one
# file verifies both: `sha256sum -c SHA256SUMS --ignore-missing` beside the download, and
# the same from inside the extracted `package/`.
{
sha256sum "$TARBALL"
find dist -type f -print0 | sort -z | xargs -0 sha256sum
} > SHA256SUMS
{
echo "### $TARBALL"
echo '```'
cat SHA256SUMS
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- name: Attach them to the release for this tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --verify-tag --title "$TAG" \
--notes "Browser client ${VERSION}. Vendoring and verification: client/README.md."
fi
gh release upload "$TAG" "$TARBALL" SHA256SUMS --clobber

- name: Report failure to the job summary
if: failure()
run: |
echo "### Client publish failed" >> "$GITHUB_STEP_SUMMARY"
echo "Nothing was published. Fix the cause and move the tag, or cut a new one." >> "$GITHUB_STEP_SUMMARY"
echo "Check which step failed: the package may have reached GitHub Packages before it." >> "$GITHUB_STEP_SUMMARY"
echo "The registry refuses a version it already holds, so a retry needs a new patch version." >> "$GITHUB_STEP_SUMMARY"
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ group needs; the reason is Azure policy, not caution about automation.
The script needs `APIM_SHARED_HEADER_VALUE`, `AUDIT_SHARED_HEADER_VALUE`, `FRONT_DOOR_ID` and
`BUDGET_CONTACT_EMAIL` exported in that shell, and `CONFIRM_PROD=yes` for a live prod deploy. The
param files read all four with no fallback, so a missing export fails the Bicep build instead of
blanking a live app setting. The two header values are the same strings eagle-demi's APIM deploy
blanking a live app setting. The script also refuses a value carrying whitespace or a literal
backslash-n, which is what `export X="$(…)"` and `echo` without `-n` leave behind: the app settings
would take it verbatim while APIM stamps the clean value, and every request would answer 401. The two header values are the same strings eagle-demi's APIM deploy
reads — its `azure/main.<env>.bicepparam` takes them from the same variable names, and both estates
must be deployed with the same values or APIM's forwarded requests are refused. They are held as
secrets on the eagle-demi `test` GitHub environment; nothing in either repo carries a value.
Expand Down
39 changes: 39 additions & 0 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,44 @@ Pushing the tag runs `.github/workflows/publish-client.yaml`: it compares the ta
`package.json` and stops there if they disagree, then typechecks, tests, builds and publishes to
GitHub Packages. The run's own `GITHUB_TOKEN` is the only credential.

It then creates the GitHub release for the tag, if it does not exist yet, and attaches two files:

| Asset | What it is |
|---|---|
| `eagle-analytics-client-X.Y.Z.tgz` | `yarn pack` of `client/`: `package.json`, `README.md` and the built `dist/`. |
| `SHA256SUMS` | Checksum of the tarball, and of each file in `dist/`. |

`dist/` is gitignored, so the tag alone carries no build. The release assets are the only thing that
ties a vendored copy to a version.

The registry will not accept a version it already holds, so a bad publish needs a new patch version
rather than a moved tag.

## Vendoring

`eagle-admin` and `eagle-public` do not install from GitHub Packages. They keep a copy under
`vendor/eagle-analytics-client/` and depend on the directory, so no token is involved. Take a new
version from the release, not from a local build. Nobody else can check a local build.

```sh
VERSION=0.1.0
gh release download "client-v${VERSION}" -R digitalspace/eagle-analytics \
-p "eagle-analytics-client-${VERSION}.tgz" -p SHA256SUMS

# Fails loudly if the tarball is not the one the release run built.
sha256sum -c SHA256SUMS --ignore-missing

tar -xzf "eagle-analytics-client-${VERSION}.tgz" # extracts to package/
( cd package && sha256sum -c ../SHA256SUMS --ignore-missing ) # checks each dist file
```

Then, in the consumer repository:

1. Replace `vendor/eagle-analytics-client/` with `package/`'s `dist/`, `package.json` and
`README.md`.
2. Record the tag and the tarball's sha256 in that directory's `README.md`, so the next person can
tell which release the checked-in `dist/` came from without rebuilding it.
3. Run `yarn install` and commit.

Nothing else needs changing: the dependency stays
`"@digitalspace/eagle-analytics-client": "file:./vendor/eagle-analytics-client"`.
33 changes: 30 additions & 3 deletions docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,42 @@ node scripts/import-penguin-history.js --env test --file daily_events_summary.cs
node scripts/import-penguin-history.js --env test --file daily_events_summary.csv
```

Two things to know about imported rows:
Four things to know about imported rows:

- Only rows whose event name is one the product emits are imported. penguin took the name from the
browser and stored it as free text, so its history also holds scanner payloads — JNDI lookups, XXE,
SQL — and this script posts to the DCR without passing the `POST /events` validator. The allow-list
is `KNOWN_EVENTS` in the script, built from the client's own events plus every `track()` name in
eagle-public and eagle-admin. Anything else is dropped, and the run reports how many rows and how
many distinct names that cost; `--verbose` lists the names, cut to 40 characters each. On the
September 2026 prod export that was 163 rows of 4943, across 150 names.

- `TimeGenerated` is the time of the import, because the Logs Ingestion API rewrites the timestamp on
anything older than two days. The historical date is in `Day`, which is the column the live rollup
rule fills too, so charts read both the same way.
- `page_views` totals a page over all time and carries no day, so its rows land on the day the page
was last seen. Time series come from `daily_events_summary`.

Confirm the row count matches the CSV, then check a twelve month chart renders.
- Event names are normalised to the names the client sends, so one chart grouped by `EventName` does
not split the same event into a historical series and a live one. penguin let the browser name its
own events, so an export holds both forms:

| penguin `event_type` | `EventName` |
|---|---|
| `page_view` | `Page Viewed` |
| `session_start` | `Session Started` |
| `session_end` | `Session Ended` |
| `user_active` | `User Active` |
| `link_click` | `Link Clicked` |
| `button_click` | `Button Clicked` |
| `user_identified` | `User Identified` |

Matching ignores case. A name not in the table is imported unchanged, which is what carries an
app-specific event such as `Document Downloaded` over with the name its dashboards already use.
Rows from the `page_views` view get `Page Viewed`: the view counts page views and carries no event
type of its own.

Confirm the row count equals the CSV rows minus the dropped count the script reports, then check a
twelve month chart renders.

## 5. Production estate and application

Expand Down
14 changes: 13 additions & 1 deletion scripts/deploy-infra.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,22 @@ esac
REQUIRED_VARS=(APIM_SHARED_HEADER_VALUE AUDIT_SHARED_HEADER_VALUE FRONT_DOOR_ID BUDGET_CONTACT_EMAIL)

for VAR in "${REQUIRED_VARS[@]}"; do
if [ -z "${!VAR:-}" ]; then
VALUE="${!VAR:-}"
if [ -z "$VALUE" ]; then
echo -e "${RED}✗ ${VAR} is not set. See --help.${NC}" >&2
exit 2
fi
# `export X="$(...)"` keeps a trailing newline and `echo` without -n leaves a literal backslash-n.
# Either travels into the app settings verbatim while APIM stamps the clean value, so every request
# 401s and nothing in the deploy output says why. The value is never printed: these are secrets.
case "$VALUE" in
*[[:space:]]*|*'\n'*)
echo -e "${RED}✗ ${VAR} contains whitespace or an escaped newline.${NC}" >&2
echo -e "${RED} Re-export it as a single line: the app settings would take it verbatim while${NC}" >&2
echo -e "${RED} APIM sends the clean value, and every request would answer 401.${NC}" >&2
exit 2
;;
esac
done

# readEnvironmentVariable only sees exported variables, so a plain assignment on the caller's line
Expand Down
128 changes: 118 additions & 10 deletions scripts/import-penguin-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ import-penguin-history.js --env <dev|test|prod> --file <csv> [--file <csv>…] [
--env value of the Env column on every imported row.
--file CSV exported from the penguin view daily_events_summary or page_views. Repeatable.
--dry-run parse and map, print the counts, post nothing.
--verbose also list the event names that were dropped, and how many rows each cost.
--help this text.

Only rows whose event name is one this product emits are imported: penguin stored the name the
browser sent, so its history also holds scanner payloads. --verbose says what was left behind.

EVENTS_DCR_ENDPOINT and EVENTS_DCR_IMMUTABLE_ID name the target rule; deploy-infra.sh prints both.
Authentication is DefaultAzureCredential, and the caller needs Monitoring Metrics Publisher on the
rule.
Expand Down Expand Up @@ -114,6 +118,75 @@ function textField(record, field) {
return String(record[field] === undefined ? '' : record[field]).trim();
}

/** The name the client sends for a page view. docs/EVENT-SCHEMA.md is the contract for all of them. */
const PAGE_VIEWED = 'Page Viewed';

/**
* penguin let the browser name its own events, so an export holds both the display names the client
* now sends and older snake_case ones. Keys are lower-cased; a name nobody listed is carried through
* as it stands, so an app-specific event keeps the name its dashboards already use.
*/
const EVENT_NAMES = new Map([
['page_view', PAGE_VIEWED],
['session_start', 'Session Started'],
['session_end', 'Session Ended'],
['user_active', 'User Active'],
['link_click', 'Link Clicked'],
['link_clicked', 'Link Clicked'],
['button_click', 'Button Clicked'],
['user_identified', 'User Identified']
]);

const eventName = (raw) => EVENT_NAMES.get(raw.toLowerCase()) || raw;

/**
* Event names this product actually emits: the ones the client sends by itself, plus every literal
* passed to `track()` in eagle-public and eagle-admin.
*
* penguin took the event name from the browser and stored it as free text, so its history also holds
* scanner payloads — JNDI lookups, XXE, SQL — that a chart would show as event names and that no
* validator ran on, because this script posts to the DCR rather than through POST /events. Anything
* not named here is dropped rather than imported. Compared lower-cased.
*/
const KNOWN_EVENTS = new Set([
...EVENT_NAMES.values(),
'Bulk Download Failed',
'Bulk Download Ready',
'Bulk Download Started',
'CAC Signup Completed',
'Comment Modal Become Member Clicked',
'Comment Modal CAC Learn More Clicked',
'Comment Modal Dismissed',
'Comment Modal Opened',
'Comment Period Banner Clicked',
'Comment Submitted',
'Document Downloaded',
'Document Filters Applied',
'Document Filters Panel Toggled',
'Document Opened',
'File Upload Attempted',
'File Upload Failed',
'File Upload Removed',
'Filters Cleared',
'Filters Panel Toggled',
'Map Base Layer Changed',
'Map Marker Clicked',
'Map Overlay Toggled',
'Map Reset View Clicked',
'News Item Clicked',
'Page Size Changed',
'Pagination Changed',
'Project Filters Applied',
'Project Filters Cleared',
'Project Filters Panel Toggled',
'Project Tab Clicked',
'Project Viewed',
'Projects View Changed',
'Search Executed',
'Search Help Clicked',
'Table Column Sorted'
].map((name) => name.toLowerCase()));

/**
* One EagleEventsDaily_CL row. ProjectId, Country and DeviceType are empty because penguin's rollups
* carried no such dimension — a chart grouped by them shows imported history as one blank bucket.
Expand Down Expand Up @@ -142,7 +215,7 @@ const SHAPES = [
map: (record, env, now) => rollupRow({
day: dayField(record, 'day'),
sourceApp: textField(record, 'source_app'),
eventName: textField(record, 'event_type'),
eventName: eventName(textField(record, 'event_type')),
page: '',
events: countField(record, 'event_count'),
sessions: countField(record, 'unique_sessions'),
Expand All @@ -157,8 +230,8 @@ const SHAPES = [
map: (record, env, now) => rollupRow({
day: dayField(record, 'last_viewed'),
sourceApp: textField(record, 'source_app'),
// penguin's own event type, kept so imported rows and its dashboards agree.
eventName: 'page_view',
// The view counts page views and carries no event type of its own.
eventName: PAGE_VIEWED,
page: textField(record, 'page_path'),
events: countField(record, 'total_views'),
sessions: countField(record, 'unique_sessions'),
Expand All @@ -172,7 +245,8 @@ const SHAPES = [
*
* @param {string} text CSV with a header row, as `\copy … csv header` writes it.
* @param {{env: string, now?: Date}} options
* @returns {{view: string, rows: object[]}}
* @returns {{view: string, rows: object[], dropped: Map<string, number>}} dropped counts rows per
* event name that is not a known product event.
*/
function toDailyRows(text, { env, now = new Date() }) {
const rows = records(text);
Expand All @@ -189,7 +263,16 @@ function toDailyRows(text, { env, now = new Date() }) {
}

const stamp = now.toISOString();
return { view: shape.view, rows: rows.map((record) => shape.map(record, env, stamp)) };
const kept = [];
const dropped = new Map();

for (const record of rows) {
const row = shape.map(record, env, stamp);
if (KNOWN_EVENTS.has(row.EventName.toLowerCase())) kept.push(row);
else dropped.set(row.EventName, (dropped.get(row.EventName) || 0) + 1);
}

return { view: shape.view, rows: kept, dropped };
}

/** A flag's value. A missing one shows up as the next flag, which would be read as a file name. */
Expand All @@ -200,13 +283,14 @@ function value(argv, index, flag) {
}

function parseArgs(argv) {
const args = { env: '', files: [], dryRun: false, help: false };
const args = { env: '', files: [], dryRun: false, verbose: false, help: false };

for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (flag === '--env') args.env = value(argv, index += 1, flag);
else if (flag === '--file') args.files.push(value(argv, index += 1, flag));
else if (flag === '--dry-run') args.dryRun = true;
else if (flag === '--verbose') args.verbose = true;
else if (flag === '--help' || flag === '-h') args.help = true;
else throw new Error(`unknown argument '${flag}'`);
}
Expand Down Expand Up @@ -241,16 +325,40 @@ async function main(argv) {
}

const rows = [];
const dropped = new Map();

for (const file of args.files) {
const { view, rows: mapped } = toDailyRows(fs.readFileSync(file, 'utf8'), { env: args.env });
logger.info(`[import] ${file}: ${mapped.length} row(s) from ${view}`);
rows.push(...mapped);
const parsed = toDailyRows(fs.readFileSync(file, 'utf8'), { env: args.env });
logger.info(`[import] ${file}: ${parsed.rows.length} row(s) from ${parsed.view}`);
// Not push(...parsed.rows): a spread passes one argument per row, and a 330k-row page_views
// export overflows the call stack.
for (const row of parsed.rows) rows.push(row);
for (const [name, count] of parsed.dropped) dropped.set(name, (dropped.get(name) || 0) + count);
}

const days = new Set(rows.map((row) => row.Day));
const events = rows.reduce((total, row) => total + row.Events, 0);
logger.info(`[import] ${rows.length} row(s), ${days.size} day(s), ${events} event(s), Env=${args.env}`);

if (dropped.size > 0) {
const rowsDropped = [...dropped.values()].reduce((total, count) => total + count, 0);
logger.warn(`[import] dropped ${rowsDropped} row(s) with unknown event name, ${dropped.size} distinct`);
if (args.verbose) {
// Truncated: these are whatever a scanner posted, and a 99-character payload in an operator's
// terminal is noise at best. Control characters go first: a name holding a newline would
// otherwise forge a log line of its own, indistinguishable from one this script wrote.
for (const [name, count] of dropped) {
const safe = [...name]
.map((ch) => { const c = ch.codePointAt(0); return c < 0x20 || c === 0x7f ? '?' : ch; })
.join('')
.slice(0, 40);
logger.warn(`[import] ${safe} (${count} row(s))`);
}
} else {
logger.warn('[import] --verbose lists them.');
}
}

if (args.dryRun) {
logger.info('[import] dry run, nothing posted.');
return 0;
Expand All @@ -270,4 +378,4 @@ if (require.main === module) {
});
}

module.exports = { parseCsv, toDailyRows, parseArgs };
module.exports = { parseCsv, toDailyRows, parseArgs, main };
Loading