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
40 changes: 40 additions & 0 deletions docs/examples/analytics/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# LOCAL DEMO ONLY. Umami starts with admin / umami: change it immediately.
# The DB password and app secret below are public demo values, NOT production secrets.
# SkillHub is an existing upstream, not built or configured by this demo.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: demo-only-change-me
healthcheck:
test: [CMD-SHELL, pg_isready -U umami -d umami]
interval: 5s
timeout: 3s
retries: 20
umami:
image: ghcr.io/umami-software/umami:3.4.1
environment:
DATABASE_URL: postgresql://umami:demo-only-change-me@postgres:5432/umami
APP_SECRET: demo-only-change-this-before-use
depends_on:
postgres:
condition: service_healthy
# Direct admin access is LOCAL setup only; never proxy the admin through edge.
ports: ["127.0.0.1:8300:3000"]
edge:
image: nginx:1.30.6-alpine
# Docker Desktop/OrbStack provide this name; native Linux needs this mapping.
extra_hosts: ["host.docker.internal:host-gateway"]
environment:
SKILLHUB_WEB_UPSTREAM: ${SKILLHUB_WEB_UPSTREAM:-host.docker.internal:80}
UMAMI_UPSTREAM: umami:3000
NGINX_ENVSUBST_FILTER: ^(SKILLHUB_WEB_UPSTREAM|UMAMI_UPSTREAM)$$
volumes:
- ./nginx.conf:/etc/nginx/templates/default.conf.template:ro
- ./observe/v1/observer.mjs:/srv/_skillhub-observe/v1/observer.mjs:ro
- ./observe/v1/skillhub-intents.mjs:/srv/_skillhub-observe/v1/skillhub-intents.mjs:ro
- ./observe/v1/umami-adapter.mjs:/srv/_skillhub-observe/v1/umami-adapter.mjs:ro
ports: ["127.0.0.1:8081:8081"]
depends_on: [umami]
85 changes: 85 additions & 0 deletions docs/examples/analytics/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# OPTIONAL deployment-owned edge template. Not included by SkillHub.
# Demo HTTP listener: publish on loopback only, behind your existing HTTPS edge.
# Substitute only SKILLHUB_WEB_UPSTREAM and UMAMI_UPSTREAM (host:port).
# Merge existing TLS, CSP, authentication, host validation and security headers.
upstream skillhub_analytics_web { server ${SKILLHUB_WEB_UPSTREAM}; }
upstream skillhub_analytics_umami { server ${UMAMI_UPSTREAM}; }
server {
listen 8081;
server_name localhost;
# Preserve the standard Web upload limit on every application proxy route.
client_max_body_size 100M;

# Install ONLY the three .mjs files here, not declarations or other files.
# A single versioned prefix serves the entry AND its relative imports.
location ^~ /_skillhub-observe/v1/ {
root /srv;
try_files $uri =404;
error_page 404 = @module_missing;
autoindex off;
types { application/javascript mjs; }
default_type application/javascript;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location @module_missing {
default_type application/javascript;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store" always;
return 404 '// module not found\n';
}
location /_skillhub-observe/ { return 404; }

# Expose only two collector endpoints, NEVER Umami's dashboard/admin API.
location = /_skillhub-umami/script.js {
limit_except GET { deny all; }
proxy_pass http://skillhub_analytics_umami/script.js;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_set_header Referer "";
proxy_hide_header Set-Cookie;
add_header X-Content-Type-Options nosniff always;
}
location = /_skillhub-umami/api/send {
limit_except POST { deny all; }
proxy_pass http://skillhub_analytics_umami/api/send;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_set_header Referer "";
proxy_hide_header Set-Cookie;
proxy_cache off;
add_header Cache-Control "no-store" always;
}
location /_skillhub-umami/ { return 404; }

# No filtering or cache-policy changes for API, auth, assets, runtime config,
# registry guides/downloads or health. Preserve original URI (no trailing /).
location ~ ^/(api/|oauth2/|login/oauth2/|\.well-known/|assets/|registry/|runtime-config\.js$|nginx-health) {
proxy_pass http://skillhub_analytics_web;
proxy_set_header Host $http_host;
}

# Only UI HTML is eligible; sub_filter's default MIME is text/html.
location / {
proxy_pass http://skillhub_analytics_web;
proxy_set_header Host $http_host;
proxy_set_header Accept-Encoding "";
proxy_set_header If-None-Match "";
proxy_set_header If-Modified-Since "";
proxy_cache off;
proxy_hide_header ETag;
proxy_hide_header Last-Modified;
proxy_hide_header Content-MD5;
proxy_hide_header Cache-Control;
proxy_hide_header Expires;
etag off;
if_modified_since off;
add_header Cache-Control "no-store" always;
# Also prevent the browser's HTTP Referer from leaking page paths/queries.
proxy_hide_header Referrer-Policy;
add_header Referrer-Policy "no-referrer" always;
sub_filter_once on;
sub_filter_last_modified off;
sub_filter '</head>' '<script type="module" src="/_skillhub-observe/v1/observer.mjs"></script></head>';
}
}
26 changes: 26 additions & 0 deletions docs/examples/analytics/observe/v1/observer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Deployment configuration, not an application setting. Review before serving.
import { DEFAULT_RULES, observeSkillhubIntents } from './skillhub-intents.mjs'
import { createUmamiAdapter } from './umami-adapter.mjs'

const config = Object.freeze({
enabled: false,
website: '00000000-0000-0000-0000-000000000000', // Replace with your Umami website UUID.
prefix: '/_skillhub-umami',
rules: { ...DEFAULT_RULES, 'skill.example.open': 'click' },
})

// Replace this hook with your consent manager's current approval state.
// It must be false until policy/consent permits collection; do not infer approval.
function hasConsent() { return false }

let dispose
function start() {
if (!config.enabled || !hasConsent() || dispose) return
const adapter = createUmamiAdapter({ website: config.website, prefix: config.prefix })
const stopObserver = observeSkillhubIntents({ rules: config.rules, onIntent: adapter.send })
dispose = () => { stopObserver(); adapter.stop() }
}
function stop() { dispose?.(); dispose = undefined }
window.skillhubAnalytics = Object.freeze({ start, stop })
// Consent manager: call start() on approval, stop() immediately on withdrawal.
start()
12 changes: 12 additions & 0 deletions docs/examples/analytics/observe/v1/skillhub-intents.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface SkillhubIntent { readonly id: string; readonly event: string }
export type RuleType = 'click' | 'submit' | 'card'
export const DEFAULT_RULES: Readonly<Record<string, RuleType>>
export function resolveRules(rules: unknown): {
active: Record<string, RuleType>
skipped: Array<{ id: string | null; reason: string }>
}
export function observeSkillhubIntents(options?: {
root?: Document | Element
rules?: unknown
onIntent?: (intent: SkillhubIntent) => unknown
}): () => void
77 changes: 77 additions & 0 deletions docs/examples/analytics/observe/v1/skillhub-intents.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Optional deployment example, NOT imported or started by SkillHub.
const activeRoots = new WeakMap()
const handledEvents = new WeakSet()
// Same grammar as web/scripts/analytics-manifest.mjs; manifest parity is tested.
const idShape = /^[a-z]+(?:\.[a-z]+){2,}$/
export const DEFAULT_RULES = Object.freeze({
'search.query.submit': 'submit',
'skill.card.open': 'card',
'suite.card.open': 'card',
'skill.download.request': 'click',
'skill.file.download': 'click',
'skill.command.copy': 'click',
'skill.prompt.copy': 'click',
'skill.publish.request': 'click',
'skill.publish.confirm': 'click',
'skill.publish.cancel': 'click',
'skill.delete.confirm': 'click',
'skill.delete.cancel': 'click',
})

// Pure validation; callers may inspect skipped without installing any listeners.
export function resolveRules(rules) {
const active = Object.create(null)
const skipped = []
try {
if (!rules || typeof rules !== 'object' || Array.isArray(rules)
|| ![Object.prototype, null].includes(Object.getPrototypeOf(rules))) {
return { active, skipped: [{ id: null, reason: 'rules must be a plain object' }] }
}
for (const [id, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(rules))) {
const rule = descriptor.value // Do not run config getters.
const reason = !idShape.test(id) ? 'invalid id shape'
: typeof rule !== 'string' ? 'non-string rule type'
: !['click', 'submit', 'card'].includes(rule) ? 'unknown rule type' : null
if (reason) skipped.push({ id, reason })
else active[id] = rule
}
} catch { skipped.push({ id: null, reason: 'unreadable rules object' }) }
return { active, skipped }
}

export function observeSkillhubIntents({ root = document, rules = DEFAULT_RULES, onIntent } = {}) {
if (activeRoots.has(root)) return activeRoots.get(root)
if (typeof onIntent !== 'function') return () => {}
const { active } = resolveRules(rules)
const observe = event => {
if (handledEvents.has(event)) return
const target = event.target?.nodeType === 1 ? event.target : event.target?.parentElement
const host = target?.closest('[data-analytics-id]')
if (!host || !root.contains(host)) return
const id = host.getAttribute('data-analytics-id')
const rule = active[id]
if (!rule || host.closest(':disabled, [aria-disabled="true"], [data-disabled]')) return
const interactive = target.closest('button, a, input, select, textarea, [role="button"], [role="link"], [role="option"], [role="menuitem"]')
if (event.type !== 'submit' && interactive && interactive !== host && host.contains(interactive)) return
const clickHost = !host.matches('form, select, textarea, input:not([type="button"]):not([type="submit"]):not([type="reset"])')
const cardHost = host.tagName === 'DIV' && host.getAttribute('role') === 'link'
const keyboardCard = rule === 'card' && cardHost && event.type === 'keydown'
&& (event.key === 'Enter' || event.key === ' ')
&& !event.repeat && !event.isComposing && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey
const accepted = (rule === 'submit' && event.type === 'submit' && host.tagName === 'FORM')
|| (rule === 'click' && clickHost && event.type === 'click')
|| (rule === 'card' && cardHost && event.type === 'click') || keyboardCard
if (!accepted) return
handledEvents.add(event)
// Never block app behavior; observe neither callback completion nor delivery success.
try { Promise.resolve(onIntent(Object.freeze({ id, event: event.type }))).catch(() => {}) } catch { /* app stays usable */ }
}
for (const type of ['click', 'submit', 'keydown']) root.addEventListener(type, observe, true)
const stop = () => {
if (activeRoots.get(root) !== stop) return
for (const type of ['click', 'submit', 'keydown']) root.removeEventListener(type, observe, true)
activeRoots.delete(root)
}
activeRoots.set(root, stop)
return stop
}
91 changes: 91 additions & 0 deletions docs/examples/analytics/observe/v1/umami-adapter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Deployment-owned adapter for Umami v3.4.1 (MIT). No SDK in the product.
// Same grammar as web/scripts/analytics-manifest.mjs; manifest parity is tested.
const idShape = /^[a-z]+(?:\.[a-z]+){2,}$/
const websiteShape = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const events = ['click', 'submit', 'keydown']

// Umami's before-send hook returns the inner payload, not the { type, payload } envelope.
export function sanitizeUmamiPayload(type, payload, website) {
let payloadWebsite, name, event
try {
payloadWebsite = payload?.website
name = payload?.name
event = payload?.data?.event
} catch { return null }
if (type !== 'event' || !websiteShape.test(website) || payloadWebsite !== website
|| typeof name !== 'string' || !idShape.test(name) || name.length > 50
|| typeof event !== 'string' || !events.includes(event)) return null
return { website: payloadWebsite, name, data: { event } }
}

export function createUmamiAdapter({ window: win = window, document: doc = document,
website, prefix = '/_skillhub-umami', timeoutMs = 3000 } = {}) {
if (!websiteShape.test(website)) return { send: async () => {}, stop() {} }
let stopped = false, loadState = 'loading', pending = 0, finishLoad
const script = doc.createElement('script')
const ready = new Promise(resolve => { finishLoad = resolve })
let loadTimer
const loaded = ok => {
if (loadState !== 'loading') return // Failure/timeout cannot be revived by a late load.
loadState = ok ? 'ready' : 'failed'
clearTimeout(loadTimer)
script.onload = script.onerror = null
finishLoad(ok)
}
win.skillhubUmamiBeforeSend = (type, payload) => {
if (stopped) return null
const sanitized = sanitizeUmamiPayload(type, payload, website)
if (!sanitized) return null
const { website: validatedWebsite, name, data: { event } } = sanitized
// Verified against pinned v3.4.1: serialization is synchronous at fetch dispatch,
// inside the SDK's try/catch, after awaiting this hook. Re-verify on SDK upgrades.
Object.defineProperty(sanitized, 'toJSON', {
enumerable: false,
value() {
if (stopped) throw new Error('Analytics stopped before dispatch')
return { website: validatedWebsite, name, data: { event } }
},
})
return sanitized
}
script.src = `${prefix}/script.js`
script.async = true
script.setAttribute('data-website-id', website)
script.setAttribute('data-auto-track', 'false')
script.setAttribute('data-host-url', prefix)
script.setAttribute('data-before-send', 'skillhubUmamiBeforeSend')
script.onload = () => loaded(true)
script.onerror = () => loaded(false)
loadTimer = setTimeout(() => loaded(false), timeoutMs)
doc.head.append(script)

return {
async send({ id, event } = {}) {
if (stopped || loadState === 'failed'
|| !sanitizeUmamiPayload('event', { website, name: id, data: { event } }, website)) return
if (loadState === 'loading') {
if (pending >= 20) return // Bounded, memory-only wait for the script; no retry.
pending++
await ready
pending--
}
let timer
try {
if (stopped || loadState !== 'ready' || typeof win.umami?.track !== 'function') return
// Each ready intent is independent, even if another SDK fetch never settles.
await Promise.race([
Promise.resolve().then(() => { if (!stopped) return win.umami.track(id, { event }) }),
new Promise(resolve => { timer = setTimeout(resolve, timeoutMs) }),
])
} catch { /* Missing/blocked/offline analytics must never reject into the app. */ }
finally { clearTimeout(timer) }
},
stop() {
stopped = true
loaded(false)
script.remove()
// Keep the denying hook and dispatch guard: no new request after stop returns.
// Only a request whose fetch already started cannot be recalled.
},
}
}
Loading
Loading