feat: add first-party copy event pipe to Tinybird

Sends rule_copy, wget_copy events on clipboard interactions,
bypassing ad blockers. Tracks user_id (localStorage apa_uid),
session_id (sessionStorage apa_sid), session/lifetime copy counts,
full rule coordinates (group/service/exporter/rule slugs + indices),
page context, and browser environment. Event name is the Tinybird
data source name, scoped to "rule" or "exporter" per copy type.
This commit is contained in:
Samuel Berthe 2026-04-15 11:34:12 +02:00
parent bb055773b4
commit 1c5f626046
No known key found for this signature in database
GPG key ID: 64863511FFBD0E3C
6 changed files with 179 additions and 6 deletions

View file

@ -8,9 +8,10 @@ interface Props {
class?: string;
withAttribution?: boolean;
nudge?: boolean;
copyData?: Record<string, unknown> | null;
}
const { targetId, label = 'Copy', variant = 'icon', class: extraClass = '', withAttribution = false, nudge = false } = Astro.props;
const { targetId, label = 'Copy', variant = 'icon', class: extraClass = '', withAttribution = false, nudge = false, copyData = null } = Astro.props;
const btnId = `copy-btn-${targetId}`;
const nudgeId = `star-nudge-${btnId}`;
---
@ -65,7 +66,7 @@ const nudgeId = `star-nudge-${btnId}`;
</button>
)}
<script define:vars={{ btnId, nudgeId, withAttribution, nudge, GITHUB_URL }}>
<script define:vars={{ btnId, nudgeId, withAttribution, nudge, GITHUB_URL, copyData }}>
const btn = document.getElementById(btnId);
if (!(btn instanceof HTMLButtonElement)) return;
if (btn.dataset.copyBound === 'true') return;
@ -126,6 +127,9 @@ const nudgeId = `star-nudge-${btnId}`;
}
}
if (copyData) {
window.dispatchEvent(new CustomEvent('apa-copy', { detail: copyData }));
}
window.dispatchEvent(new CustomEvent('copy-success'));
});
</script>

View file

@ -8,7 +8,10 @@ interface Props {
exporter: Exporter;
service: Service;
groupIndex: number;
groupName: string;
groupSlug: string;
serviceIndex: number;
serviceSlug: string;
exporterIndex: number;
showExporterNumber: boolean;
}
@ -17,7 +20,10 @@ const {
exporter,
service,
groupIndex,
groupName,
groupSlug,
serviceIndex,
serviceSlug,
exporterIndex,
showExporterNumber,
} = Astro.props;
@ -68,7 +74,26 @@ const exporterPrefix = showExporterNumber
</div>
<div class="flex items-center gap-2">
<CopyButton targetId={allRulesId} label="Copy all" variant="text" withAttribution={true} />
<CopyButton
targetId={allRulesId}
label="Copy all"
variant="text"
withAttribution={true}
copyData={{
name: 'rule_copy',
scope: 'exporter',
group_index: groupIndex,
group_name: groupName,
group_slug: groupSlug,
service_index: serviceIndex,
service_name: service.name,
service_slug: serviceSlug,
exporter_index: exporterIndex,
exporter_name: exporter.name ?? null,
exporter_slug: exporter.slug,
with_attribution: true,
}}
/>
</div>
</div>
@ -88,7 +113,23 @@ const exporterPrefix = showExporterNumber
</svg>
<pre id={wgetId} class="text-xs font-mono text-slate-600 dark:text-slate-300 overflow-x-auto whitespace-pre">{wgetCommand}</pre>
</div>
<CopyButton targetId={wgetId} variant="icon" />
<CopyButton
targetId={wgetId}
variant="icon"
copyData={{
name: 'wget_copy',
scope: 'exporter',
group_index: groupIndex,
group_name: groupName,
group_slug: groupSlug,
service_index: serviceIndex,
service_name: service.name,
service_slug: serviceSlug,
exporter_index: exporterIndex,
exporter_name: exporter.name ?? null,
exporter_slug: exporter.slug,
}}
/>
</div>
)}
@ -114,6 +155,26 @@ const exporterPrefix = showExporterNumber
rule={rule}
anchorId={anchorId}
ruleNumber={ruleNumber}
copyData={{
name: 'rule_copy',
scope: 'rule',
group_index: groupIndex,
group_name: groupName,
group_slug: groupSlug,
service_index: serviceIndex,
service_name: service.name,
service_slug: serviceSlug,
exporter_index: exporterIndex,
exporter_name: exporter.name ?? null,
exporter_slug: exporter.slug,
rule_index: ruleIdx + 1,
rule_slug: anchorId,
rule_name: rule.name,
rule_severity: rule.severity,
rule_for: rule.for ?? null,
rule_number: ruleNumber,
with_attribution: false,
}}
/>
);
})}

View file

@ -8,9 +8,10 @@ interface Props {
rule: Rule;
anchorId: string;
ruleNumber: string;
copyData?: Record<string, unknown> | null;
}
const { rule, anchorId, ruleNumber } = Astro.props;
const { rule, anchorId, ruleNumber, copyData = null } = Astro.props;
const yamlContent = formatRuleAsYaml(rule);
const codeId = `code-${anchorId}`;
---
@ -37,7 +38,7 @@ const codeId = `code-${anchorId}`;
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</a>
<CopyButton targetId={codeId} variant="icon" nudge={true} />
<CopyButton targetId={codeId} variant="icon" nudge={true} copyData={copyData} />
</div>
</div>

View file

@ -98,5 +98,12 @@ const canonical = canonicalUrl ?? `${SITE_ORIGIN}${base}${Astro.url.pathname.rep
<Footer base={base} />
<StarToast />
<script>
import { record } from '../scripts/pipe';
window.addEventListener('apa-copy', (e: Event) => {
const { name, ...data } = (e as CustomEvent<Record<string, unknown>>).detail;
record(name as string, data);
});
</script>
</body>
</html>

View file

@ -124,7 +124,10 @@ const jsonLd = {
exporter={exporter}
service={service}
groupIndex={groupIndex}
groupName={group.name}
groupSlug={groupSlug}
serviceIndex={serviceIndex}
serviceSlug={serviceSlug}
exporterIndex={expIdx + 1}
showExporterNumber={service.exporters.length > 1}
/>

97
site/src/scripts/pipe.ts Normal file
View file

@ -0,0 +1,97 @@
// First-party ingest pipe — records copy events to Tinybird.
// Naming deliberately avoids ad-blocker filter-list keywords
// (track, analytics, telemetry, metrics, beacon, pixel, collect, stat, signal).
// const PIPE_URL = 'https://api.eu-west-1.aws.tinybird.co/v0/events?name=';
const PIPE_URL = 'https://tb.samber.dev/?name=';
const PIPE_KEY = 'p.eyJ1IjogIjQ1MzY3NjRjLTNiY2MtNDU0My04M2ZjLWM0MDUxZGFhMGM5ZiIsICJpZCI6ICJmOWZjOGQ3Yi05ZGE1LTRiZjEtYjg4YS1mNGFlNTRkNTU3YWUiLCAiaG9zdCI6ICJhd3MtZXUtd2VzdC0xIn0.-zLRexgT8W2-derRM6jVCXzUkz54sMsiOy45WO6GglM';
function uid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
function getUserId(): string {
try {
let id = localStorage.getItem('apa_uid');
if (!id) { id = uid(); localStorage.setItem('apa_uid', id); }
return id;
} catch { return 'anon'; }
}
function getSessionId(): string {
try {
let id = sessionStorage.getItem('apa_sid');
if (!id) { id = uid(); sessionStorage.setItem('apa_sid', id); }
return id;
} catch { return 'anon'; }
}
function bumpSessionCount(): number {
try {
const n = parseInt(sessionStorage.getItem('apa_sc') ?? '0', 10);
sessionStorage.setItem('apa_sc', String(n + 1));
return n + 1;
} catch { return 0; }
}
function bumpLifetimeCount(): number {
try {
const n = parseInt(localStorage.getItem('apa_lc') ?? '0', 10);
localStorage.setItem('apa_lc', String(n + 1));
return n + 1;
} catch { return 0; }
}
function getPageContext() {
const path = location.pathname;
let page_type: 'service' | 'home' | 'guide' | 'other' = 'other';
if (path.includes('/rules/') && path.split('/').filter(Boolean).length >= 4) {
page_type = 'service';
} else if (path.split('/').filter(Boolean).length <= 1) {
page_type = 'home';
} else if (['alertmanager', 'blackbox-exporter', 'sleep-peacefully'].some((g) => path.includes(g))) {
page_type = 'guide';
}
return {
page_path: path,
page_type,
referrer: document.referrer || undefined,
anchor_hash: location.hash || undefined,
};
}
// Eagerly init IDs on module load so they exist before first copy
getUserId();
getSessionId();
export function record(name: string, data: Record<string, unknown>): void {
const payload = {
timestamp: new Date().toISOString(),
transaction_id: uid(),
name: "awesome_prometheus_alerts_"+name,
user_id: getUserId(),
session_id: getSessionId(),
session_copy_count: bumpSessionCount(),
lifetime_copy_count: bumpLifetimeCount(),
...data,
...getPageContext(),
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
viewport_w: window.innerWidth,
viewport_h: window.innerHeight,
color_scheme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
user_agent: navigator.userAgent,
is_bot: /bot|crawl|spider/i.test(navigator.userAgent),
};
fetch(PIPE_URL+"awesome_prometheus_alerts_"+name, {
method: 'POST',
body: JSON.stringify(payload),
headers: { Authorization: `Bearer ${PIPE_KEY}` },
keepalive: true,
}).catch(console.error);
}