From a3fbe5b5d43f36a846847beca68a0aab20f9e2f0 Mon Sep 17 00:00:00 2001
From: Cyborg Viktor <307551610+vpetersson-bot@users.noreply.github.com>
Date: Fri, 14 Aug 2026 07:18:11 +0000
Subject: [PATCH] feat: pin the GA4 client_id to the device
GA4's own client_id lives in the _ga cookie and these players largely boot with
fresh storage, so it churns and does not identify a screen. Where a Screenly
player sends asset metadata there is now a stable device id, and signage-kit
derives a client_id from the same hash that produces player_device, so the two
always agree about which screen this is.
The inline
tag becomes analyticsBootstrap() from the kit. It has to own
the config call, because client_id is stamped onto each event as it is sent; it
cannot move into main.js, because screens that never load the bundle would then
report nothing at all; and it cannot be rendered into this HTML, because the page
is edge-cached with no per-screen component and every screen would inherit
whichever one warmed the cache. So it fetches the no-store /api/player route at
runtime.
Fails open: profile arrival, fetch rejection, a non-ok response and a timeout all
end in exactly one config call, so an offline screen still reports with GA4's
default behaviour.
Also bumps signage-kit to 2026.8.8.
Co-Authored-By: Claude Opus 5 (1M context)
---
bun.lock | 4 ++--
package.json | 2 +-
src/components/Layout.tsx | 32 +++++++++++++-------------------
3 files changed, 16 insertions(+), 22 deletions(-)
diff --git a/bun.lock b/bun.lock
index ddd9478..080c5a8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,7 +5,7 @@
"": {
"name": "screenly-rss-reader",
"dependencies": {
- "@screenly-labs/signage-kit": "github:Screenly-Labs/signage-kit#2026.8.6",
+ "@screenly-labs/signage-kit": "github:Screenly-Labs/signage-kit#2026.8.8",
"hono": "^4.12.33",
"qrcode-generator": "^2.0.4",
},
@@ -191,7 +191,7 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
- "@screenly-labs/signage-kit": ["@screenly-labs/signage-kit@github:Screenly-Labs/signage-kit#7b0d8f1", { "dependencies": { "@fontsource-variable/bricolage-grotesque": "^5.3.0", "@fontsource-variable/fraunces": "^5.3.0", "@fontsource-variable/hanken-grotesk": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/newsreader": "^5.3.0", "@fontsource/space-mono": "^5.3.0" }, "peerDependencies": { "@csstools/postcss-cascade-layers": "^6", "browserslist": "^4", "esbuild": "^0.28", "lightningcss": "^1.32", "postcss": "^8" }, "optionalPeers": ["@csstools/postcss-cascade-layers", "postcss"] }, "Screenly-Labs-signage-kit-7b0d8f1", "sha512-x61h6zlMCOW0MYcuIQgNeZF3TEI02rmafrASohDN5NT/PD0YSnANfWcKbZx32ItiC3fDihpeQH5c4Iu5GiKUiw=="],
+ "@screenly-labs/signage-kit": ["@screenly-labs/signage-kit@github:Screenly-Labs/signage-kit#85934fa", { "dependencies": { "@fontsource-variable/bricolage-grotesque": "^5.3.0", "@fontsource-variable/fraunces": "^5.3.0", "@fontsource-variable/hanken-grotesk": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/newsreader": "^5.3.0", "@fontsource/space-mono": "^5.3.0" }, "peerDependencies": { "@csstools/postcss-cascade-layers": "^6", "browserslist": "^4", "esbuild": "^0.28", "lightningcss": "^1.32", "postcss": "^8" }, "optionalPeers": ["@csstools/postcss-cascade-layers", "postcss"] }, "Screenly-Labs-signage-kit-85934fa", "sha512-RcJ7vWlJWT/TFu5YdrAQAbqaiHnqobWRk2XwaC0kW1X+bCzJoDWT9kS/pMDxGEeNEixMZWtEG/vjBdg/x0fIOw=="],
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
diff --git a/package.json b/package.json
index c837dea..847c800 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
},
"license": "AGPL-3.0-only",
"dependencies": {
- "@screenly-labs/signage-kit": "github:Screenly-Labs/signage-kit#2026.8.6",
+ "@screenly-labs/signage-kit": "github:Screenly-Labs/signage-kit#2026.8.8",
"hono": "^4.12.33",
"qrcode-generator": "^2.0.4"
},
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 7c1c9d5..f5a6601 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -1,4 +1,6 @@
import { html, raw } from 'hono/html'
+import { analyticsBootstrap } from '@screenly-labs/signage-kit/analytics-bootstrap'
+import { PLAYER_PROFILE_PATH } from '@screenly-labs/signage-kit/analytics-server'
import { GATE } from '@screenly-labs/signage-kit/gate'
import type { Child } from 'hono/jsx'
@@ -20,32 +22,24 @@ const sentryScript = (id?: string) =>
? html``
: ''
-// Inline a value as a JS object literal inside a ` would close the tag early,
-// and U+2028/U+2029 are valid in JSON but not in JS string literals. Escaping
-// them as \u… keeps the JSON valid while neutralizing both hazards, so a feed
-// title can never break out of the script.
-const SCRIPT_UNSAFE = /[<>\u2028\u2029]/g
-const jsonForScript = (value: unknown): string =>
- JSON.stringify(value).replace(
- SCRIPT_UNSAFE,
- (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`
- )
-
// The `source`/`source_title` on the config call ride along on GA4's automatic
// page_view, so a visit is attributed to its feed (main.ts tags the same pair on
// every later event).
+// The kit bootstrap owns the config call so it can pin client_id to the Screenly device id,
+// making one screen one GA4 user: GA4's own client_id lives in the _ga cookie and these players
+// largely boot with fresh storage, so it churns. The feed pair is passed as configParams and
+// merged under client_id, so the feed attribution above is unchanged.
const gaScript = (id?: string, feedId?: string, feedTitle?: string) =>
id
? html`
- `
+ ${raw(
+ analyticsBootstrap({
+ gaId: id,
+ profilePath: PLAYER_PROFILE_PATH,
+ configParams: { source: feedId ?? '', source_title: feedTitle ?? '' }
+ })
+ )}`
: ''
const Layout = (props: LayoutProps) => html`