From 1b03e01b9115451ff39c7e97982a7714871c917c Mon Sep 17 00:00:00 2001 From: Xavier Rincon Date: Thu, 8 Oct 2026 17:36:17 +0200 Subject: [PATCH 1/5] implement insights --- README.md | 25 + landing/package.json | 1 + landing/pnpm-lock.yaml | 16 + landing/src/components/Analytics.astro | 100 ++++ landing/src/i18n/en.ts | 50 ++ landing/src/i18n/es.ts | 50 ++ landing/src/i18n/index.ts | 2 + landing/src/lib/analytics-dashboard.ts | 457 ++++++++++++++++++ landing/src/pages/analytics.astro | 18 + landing/src/pages/es/analytics.astro | 18 + messages/en.json | 21 +- .../migration.sql | 5 + prisma/schema.prisma | 8 + src/app/api/insights/route.ts | 44 ++ .../components/insights-settings.tsx | 83 ++++ .../settings-modal/settings-modal.tsx | 6 +- src/components/welcome-scan-state.tsx | 1 + src/features/insights/consts.ts | 11 + src/features/insights/domain.ts | 52 ++ src/features/insights/hooks/use-insights.ts | 29 ++ src/features/insights/insights-scheduler.ts | 21 + src/features/insights/insights.repository.ts | 69 +++ .../insights/insights.service.test.ts | 140 ++++++ src/features/insights/insights.service.ts | 145 ++++++ src/instrumentation.ts | 7 + src/lib/date.ts | 1 + 26 files changed, 1375 insertions(+), 5 deletions(-) create mode 100644 landing/src/components/Analytics.astro create mode 100644 landing/src/lib/analytics-dashboard.ts create mode 100644 landing/src/pages/analytics.astro create mode 100644 landing/src/pages/es/analytics.astro create mode 100644 prisma/migrations/20261008000000_add_app_properties/migration.sql create mode 100644 src/app/api/insights/route.ts create mode 100644 src/components/settings-modal/components/insights-settings.tsx create mode 100644 src/features/insights/consts.ts create mode 100644 src/features/insights/domain.ts create mode 100644 src/features/insights/hooks/use-insights.ts create mode 100644 src/features/insights/insights-scheduler.ts create mode 100644 src/features/insights/insights.repository.ts create mode 100644 src/features/insights/insights.service.test.ts create mode 100644 src/features/insights/insights.service.ts create mode 100644 src/instrumentation.ts diff --git a/README.md b/README.md index 32466b1b..c015c5fb 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,7 @@ pnpm dev # Development mode | `MUSIC_FOLDERS` | No | Comma-separated list of paths to music directories. Defaults to `/music` if not set. | | `PUID` | No | User ID for the container process. Defaults to `1000`. (Docker only) | | `PGID` | No | Group ID for the container process. Defaults to `1000`. (Docker only) | +| `TAGR_INSIGHTS` | No | Set to `false` to stop sending [anonymous usage statistics](#anonymous-usage-statistics). `DO_NOT_TRACK=1` does the same. | --- @@ -278,6 +279,30 @@ and play counts inside Tagr are recorded regardless of whether scrobbling is con --- +## Anonymous usage statistics + +Once a day, Tagr sends a small anonymous report to `https://analytics.tagr.xavirincon.com/collect` so we can tell +how many instances exist and which features are worth working on. The aggregated results are public at +. + +The report contains only counts and flags: + +- A random instance ID generated on first run (not derived from anything about you or your machine) +- Tagr version, OS, CPU architecture, Node version and whether it runs in a container +- Number of songs, music folders and songs per file format +- Number of users, and how many played something in the last 7 days +- Tag edits, edited songs and plays in the last 7 days +- How many smart playlists, saved filters, active share links and scrobbling accounts exist + +It never includes file names, paths, tags, user names, tokens or your IP address (the server does not store it). +The first report goes out 30 minutes after start-up, then every 24 hours. Admins can see the exact JSON under +**Settings → Usage statistics**. + +To opt out, untick it in **Settings → Usage statistics**, or set `TAGR_INSIGHTS=false` (or `DO_NOT_TRACK=1`) to +turn it off for good. The collector is open source: [suitux/tagr-insights](https://github.com/suitux/tagr-insights). + +--- + ## Docker Volumes | Container Path | Purpose | diff --git a/landing/package.json b/landing/package.json index bc292649..ef18b843 100644 --- a/landing/package.json +++ b/landing/package.json @@ -19,6 +19,7 @@ "@fontsource-variable/geist-mono": "^5.2.8", "@tailwindcss/vite": "^4.1.18", "astro": "^5.16.0", + "chart.js": "^4.5.1", "sharp": "^0.34.5", "tailwindcss": "^4.1.18" }, diff --git a/landing/pnpm-lock.yaml b/landing/pnpm-lock.yaml index a94bea51..8aae3ddf 100644 --- a/landing/pnpm-lock.yaml +++ b/landing/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: astro: specifier: ^5.16.0 version: 5.18.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(typescript@5.9.3) + chart.js: + specifier: ^4.5.1 + version: 4.5.1 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -576,6 +579,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -1042,6 +1048,10 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -2439,6 +2449,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@oslojs/encoding@1.1.0': {} '@resvg/resvg-js-android-arm-eabi@2.6.2': @@ -2875,6 +2887,10 @@ snapshots: character-entities@2.0.2: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 diff --git a/landing/src/components/Analytics.astro b/landing/src/components/Analytics.astro new file mode 100644 index 00000000..af5de142 --- /dev/null +++ b/landing/src/components/Analytics.astro @@ -0,0 +1,100 @@ +--- +import { t, LINKS, type Locale } from '../i18n'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const d = t(locale); +const a = d.analytics; + +// Overridable for local work against `wrangler dev` (PUBLIC_INSIGHTS_API=http://localhost:8787). +const api = import.meta.env.PUBLIC_INSIGHTS_API ?? LINKS.insightsApi; + +const ranges = ['30d', '90d', '1y', 'all'] as const; +const tiles = ['instances', 'songs', 'edits', 'docker'] as const; +const charts = ['instances', 'versions', 'platforms', 'librarySizes', 'features', 'activity', 'formats'] as const; +--- + +
+
+

{a.h1}

+

{a.intro}

+

+ {a.optOut} + + {a.whatIsSent} + +

+
+ +
+
+ { + ranges.map((range) => ( + + )) + } +
+

{a.loading}

+
+ +
+
+ { + tiles.map((tile) => ( +
+
{a.tiles[tile]}
+
+ – +
+
+
+ )) + } +
+ +
+ { + charts.map((chart) => ( +
+

+ {a.charts[chart].title} +

+

{a.charts[chart].caption}

+
+ +
+
+ + {a.showTable} + +
+
+
+ )) + } +
+
+
+ + diff --git a/landing/src/i18n/en.ts b/landing/src/i18n/en.ts index bc298bf5..c001d884 100644 --- a/landing/src/i18n/en.ts +++ b/landing/src/i18n/en.ts @@ -12,6 +12,12 @@ export const en = { 'How to install and configure Tagr, the self hosted music metadata editor. Docker Compose, environment variables, music folder mounting, and troubleshooting.', ogTitle: 'Install Tagr: Docker setup and configuration', }, + analytics: { + title: 'Tagr usage analytics: anonymous statistics from every instance', + description: + 'Public, aggregated and anonymous usage statistics sent by Tagr instances: active installations, versions, platforms, library sizes and feature adoption over time.', + ogTitle: 'Tagr usage analytics', + }, notFound: { title: 'Page not found: Tagr', description: 'That page does not exist. Head back to the Tagr landing page or the install docs.', @@ -429,6 +435,7 @@ export const en = { links: [ { label: 'GitHub', href: 'https://github.com/suitux/Tagr', external: true }, { label: 'Releases', href: 'https://github.com/suitux/Tagr/releases', external: true }, + { label: 'Usage statistics', href: '/analytics/' }, { label: 'Issues', href: 'https://github.com/suitux/Tagr/issues', external: true }, { label: 'License', href: 'https://github.com/suitux/Tagr/blob/main/LICENSE', external: true }, ], @@ -445,6 +452,49 @@ export const en = { rights: 'Tagr, AGPL-3.0', }, + analytics: { + h1: 'Usage analytics', + intro: + 'Every Tagr instance sends one anonymous report a day: version, platform, library size and which features are in use. Never file names, paths, tags or user names. These are the aggregated results, updated daily.', + optOut: 'To opt out, turn it off in Settings or set TAGR_INSIGHTS=false.', + whatIsSent: 'See exactly what is sent', + rangeLabel: 'Time range', + ranges: { '30d': '30 days', '90d': '90 days', '1y': '1 year', all: 'All time' }, + loading: 'Loading statistics…', + error: 'Statistics could not be loaded. Try again later.', + empty: 'No data yet. Statistics appear here a day after the first reports arrive.', + updated: 'Updated {date}', + vsStart: '{delta} since {date}', + showTable: 'Show data as a table', + other: 'Other', + date: 'Date', + week: 'Week of', + tiles: { + instances: 'Active installations', + songs: 'Songs managed', + edits: 'Tag edits, last 7 days', + docker: 'Running in Docker', + }, + charts: { + instances: { title: 'Active installations', caption: 'Instances that reported on each day.' }, + versions: { title: 'Tagr versions', caption: 'Installations per minor version. Shows how fast new releases are adopted.' }, + platforms: { title: 'Operating system and architecture', caption: 'Share of installations.' }, + librarySizes: { title: 'Library size', caption: 'Share of installations by number of songs.' }, + features: { title: 'Feature adoption', caption: 'Share of installations using each feature.' }, + activity: { title: 'Activity', caption: 'Tag edits and plays in the previous 7 days, summed over every installation.' }, + formats: { title: 'File formats', caption: 'Share of all songs managed by Tagr.' }, + }, + features: { + metadataEditing: 'Tag editing', + smartPlaylists: 'Smart playlists', + savedFilters: 'Saved filters', + multiUser: 'Several users', + scrobbling: 'Scrobbling', + sharedLinks: 'Shared links', + }, + activity: { metadataEdits7d: 'Tag edits', listens7d: 'Plays' }, + }, + notFound: { h1: 'That page does not exist', body: 'The link is dead or the page moved. Two places worth trying:', diff --git a/landing/src/i18n/es.ts b/landing/src/i18n/es.ts index 53e10778..8b883004 100644 --- a/landing/src/i18n/es.ts +++ b/landing/src/i18n/es.ts @@ -14,6 +14,12 @@ export const es: Dict = { 'Cómo instalar y configurar Tagr, el editor de metadatos de música self hosted. Docker Compose, variables de entorno, montaje de carpetas de música y resolución de problemas.', ogTitle: 'Instalar Tagr: Docker y configuración', }, + analytics: { + title: 'Estadísticas de uso de Tagr: datos anónimos de cada instalación', + description: + 'Estadísticas de uso públicas, agregadas y anónimas que envían las instalaciones de Tagr: instalaciones activas, versiones, plataformas, tamaño de biblioteca y adopción de funciones a lo largo del tiempo.', + ogTitle: 'Estadísticas de uso de Tagr', + }, notFound: { title: 'Página no encontrada: Tagr', description: 'Esa página no existe. Vuelve a la portada de Tagr o a la guía de instalación.', @@ -431,6 +437,7 @@ export const es: Dict = { links: [ { label: 'GitHub', href: 'https://github.com/suitux/Tagr', external: true }, { label: 'Versiones', href: 'https://github.com/suitux/Tagr/releases', external: true }, + { label: 'Estadísticas de uso', href: '/es/analytics/' }, { label: 'Incidencias', href: 'https://github.com/suitux/Tagr/issues', external: true }, { label: 'Licencia', href: 'https://github.com/suitux/Tagr/blob/main/LICENSE', external: true }, ], @@ -447,6 +454,49 @@ export const es: Dict = { rights: 'Tagr, AGPL-3.0', }, + analytics: { + h1: 'Estadísticas de uso', + intro: + 'Cada instalación de Tagr envía un informe anónimo al día: versión, plataforma, tamaño de la biblioteca y qué funciones usa. Nunca nombres de archivo, rutas, etiquetas ni nombres de usuario. Estos son los resultados agregados, actualizados a diario.', + optOut: 'Para no enviarlo, desactívalo en Ajustes o define TAGR_INSIGHTS=false.', + whatIsSent: 'Ver exactamente qué se envía', + rangeLabel: 'Periodo', + ranges: { '30d': '30 días', '90d': '90 días', '1y': '1 año', all: 'Todo' }, + loading: 'Cargando estadísticas…', + error: 'No se han podido cargar las estadísticas. Inténtalo más tarde.', + empty: 'Aún no hay datos. Las estadísticas aparecen un día después de que lleguen los primeros informes.', + updated: 'Actualizado el {date}', + vsStart: '{delta} desde el {date}', + showTable: 'Ver los datos en una tabla', + other: 'Otras', + date: 'Fecha', + week: 'Semana del', + tiles: { + instances: 'Instalaciones activas', + songs: 'Canciones gestionadas', + edits: 'Ediciones de etiquetas, últimos 7 días', + docker: 'Funcionando en Docker', + }, + charts: { + instances: { title: 'Instalaciones activas', caption: 'Instalaciones que enviaron su informe cada día.' }, + versions: { title: 'Versiones de Tagr', caption: 'Instalaciones por versión. Muestra lo rápido que se adoptan las nuevas releases.' }, + platforms: { title: 'Sistema operativo y arquitectura', caption: 'Porcentaje de instalaciones.' }, + librarySizes: { title: 'Tamaño de la biblioteca', caption: 'Porcentaje de instalaciones según su número de canciones.' }, + features: { title: 'Adopción de funciones', caption: 'Porcentaje de instalaciones que usan cada función.' }, + activity: { title: 'Actividad', caption: 'Ediciones de etiquetas y reproducciones de los 7 días anteriores, sumando todas las instalaciones.' }, + formats: { title: 'Formatos de archivo', caption: 'Porcentaje de todas las canciones que gestiona Tagr.' }, + }, + features: { + metadataEditing: 'Edición de etiquetas', + smartPlaylists: 'Listas inteligentes', + savedFilters: 'Filtros guardados', + multiUser: 'Varios usuarios', + scrobbling: 'Scrobbling', + sharedLinks: 'Enlaces compartidos', + }, + activity: { metadataEdits7d: 'Ediciones', listens7d: 'Reproducciones' }, + }, + notFound: { h1: 'Esa página no existe', body: 'El enlace está roto o la página se movió. Dos sitios que merece la pena probar:', diff --git a/landing/src/i18n/index.ts b/landing/src/i18n/index.ts index 32911d73..e448620c 100644 --- a/landing/src/i18n/index.ts +++ b/landing/src/i18n/index.ts @@ -40,4 +40,6 @@ export const LINKS = { image: 'ghcr.io/suitux/tagr:latest', version: '1.8.6', license: 'https://www.gnu.org/licenses/agpl-3.0.html', + insightsApi: 'https://analytics.tagr.xavirincon.com', + insightsDocs: 'https://github.com/suitux/Tagr#anonymous-usage-statistics', } as const; diff --git a/landing/src/lib/analytics-dashboard.ts b/landing/src/lib/analytics-dashboard.ts new file mode 100644 index 00000000..14c979dd --- /dev/null +++ b/landing/src/lib/analytics-dashboard.ts @@ -0,0 +1,457 @@ +import { + CategoryScale, + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartDataset, + type Plugin, +} from 'chart.js'; +import type { en } from '../i18n/en'; + +Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip, Legend); + +type Labels = (typeof en)['analytics']; +type Range = '30d' | '90d' | '1y' | 'all'; +type ChartId = 'instances' | 'versions' | 'platforms' | 'librarySizes' | 'features' | 'activity' | 'formats'; + +/** Mirrors DailySummary in tagr-insights (src/summarize.ts). */ +interface DailySummary { + instances: number; + versions: Record; + platforms: Record; + containerized: { docker: number; bare: number }; + librarySizes: Record; + fileExtensions: Record; + features: Record; + totals: { songs: number; users: number; metadataEdits7d: number; listens7d: number }; +} + +interface SummaryResponse { + range: Range; + granularity: 'day' | 'week'; + updatedAt: string | null; + totalInstances: number; + history: { day: string; summary: DailySummary }[]; +} + +interface Series { + label: string; + values: number[]; + color: string; +} + +interface ChartSpec { + kind: 'line' | 'stacked' | 'stacked-percent'; + series: Series[]; + percentAxis?: boolean; +} + +// Dark categorical palette, validated against the card surface (#171717): every adjacent pair +// clears CVD ΔE 8 and normal-vision ΔE 15, every slot clears 3:1. Assigned in this fixed order. +const CATEGORICAL = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300']; +// "Other" is not an entity, so it gets a neutral gray instead of a seventh hue. +const OTHER = '#6e6e6e'; +// Library sizes are ordered buckets: one hue, dark → light (validated with --ordinal). +const ORDINAL = ['#184f95', '#256abf', '#3987e5', '#6da7ec', '#9ec5f4', '#cde2fb']; + +const SURFACE = '#171717'; +const GRID = 'rgba(255, 255, 255, 0.08)'; +const MUTED = '#a1a1a1'; +const FOREGROUND = '#fafafa'; + +const LIBRARY_BINS = ['0', '1-1k', '1k-10k', '10k-50k', '50k-100k', '100k+']; +const FEATURE_KEYS = ['metadataEditing', 'smartPlaylists', 'savedFilters', 'multiUser', 'scrobbling', 'sharedLinks'] as const; +const TOP_N = 5; + +/** Vertical hairline at the hovered X, so the reader aims at a date rather than a 2px line. */ +const crosshair: Plugin<'line'> = { + id: 'crosshair', + afterDatasetsDraw(chart) { + const active = chart.tooltip?.getActiveElements(); + if (!active?.length) return; + const { ctx, chartArea } = chart; + const x = active[0].element.x; + ctx.save(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, chartArea.top); + ctx.lineTo(x, chartArea.bottom); + ctx.stroke(); + ctx.restore(); + }, +}; + +function compareVersions(a: string, b: string): number { + const [aMajor, aMinor] = a.split('.').map(Number); + const [bMajor, bMinor] = b.split('.').map(Number); + return aMajor - bMajor || aMinor - bMinor; +} + +/** + * Keeps the TOP_N keys by their latest value and folds the rest into "Other". `order` decides + * the slot each kept key gets, so a key keeps its color for as long as it stays in the view. + */ +function topSeries( + maps: Record[], + otherLabel: string, + order: (a: string, b: string) => number = (a, b) => a.localeCompare(b), +): Series[] { + const latest = maps.at(-1) ?? {}; + const keys = new Set(maps.flatMap((map) => Object.keys(map))); + const ranked = [...keys].sort((a, b) => (latest[b] ?? 0) - (latest[a] ?? 0)); + const kept = ranked.slice(0, TOP_N).sort(order); + const folded = ranked.slice(TOP_N); + + const series: Series[] = kept.map((key, index) => ({ + label: key, + values: maps.map((map) => map[key] ?? 0), + color: CATEGORICAL[index], + })); + + if (folded.length > 0) { + series.push({ + label: otherLabel, + values: maps.map((map) => folded.reduce((sum, key) => sum + (map[key] ?? 0), 0)), + color: OTHER, + }); + } + return series; +} + +function toPercent(series: Series[]): Series[] { + const totals = series[0]?.values.map((_, index) => series.reduce((sum, item) => sum + item.values[index], 0)) ?? []; + return series.map((item) => ({ + ...item, + values: item.values.map((value, index) => (totals[index] ? (value / totals[index]) * 100 : 0)), + })); +} + +function buildSpecs(history: SummaryResponse['history'], labels: Labels): Record { + const summaries = history.map((point) => point.summary); + const instances = summaries.map((summary) => summary.instances); + + return { + instances: { + kind: 'line', + series: [{ label: labels.charts.instances.title, values: instances, color: CATEGORICAL[0] }], + }, + versions: { + kind: 'stacked', + series: topSeries( + summaries.map((summary) => summary.versions), + labels.other, + compareVersions, + ), + }, + platforms: { + kind: 'stacked-percent', + series: toPercent(topSeries(summaries.map((summary) => summary.platforms), labels.other)), + }, + librarySizes: { + kind: 'stacked-percent', + series: toPercent( + LIBRARY_BINS.map((bin, index) => ({ + label: bin, + values: summaries.map((summary) => summary.librarySizes[bin] ?? 0), + color: ORDINAL[index], + })), + ), + }, + features: { + kind: 'line', + percentAxis: true, + series: FEATURE_KEYS.map((key, index) => ({ + label: labels.features[key], + values: summaries.map((summary) => + summary.instances ? ((summary.features[key] ?? 0) / summary.instances) * 100 : 0, + ), + color: CATEGORICAL[index], + })), + }, + activity: { + kind: 'line', + series: (['metadataEdits7d', 'listens7d'] as const).map((key, index) => ({ + label: labels.activity[key], + values: summaries.map((summary) => summary.totals[key]), + color: CATEGORICAL[index], + })), + }, + formats: { + kind: 'stacked-percent', + series: toPercent(topSeries(summaries.map((summary) => summary.fileExtensions), labels.other)), + }, + }; +} + +export function initAnalyticsDashboard(root: HTMLElement): void { + const api = root.dataset.api!.replace(/\/$/, ''); + const locale = root.dataset.locale ?? 'en'; + const labels = JSON.parse(root.dataset.labels!) as Labels; + + const status = root.querySelector('.js-status')!; + const content = root.querySelector('.js-content')!; + const rangeButtons = [...root.querySelectorAll('.js-range')]; + + const integer = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }); + const compact = new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 }); + const percent = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); + const signed = new Intl.NumberFormat(locale, { signDisplay: 'exceptZero', maximumFractionDigits: 0 }); + + const charts = new Map>(); + let lastSpecs: Record | null = null; + let lastDates: string[] = []; + let lastGranularity: SummaryResponse['granularity'] = 'day'; + + function formatDate(day: string, withYear: boolean): string { + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: withYear ? 'numeric' : undefined, + timeZone: 'UTC', + }).format(new Date(`${day}T00:00:00Z`)); + } + + function formatValue(value: number, spec: ChartSpec): string { + return spec.kind === 'stacked-percent' || spec.percentAxis ? `${percent.format(value)} %` : integer.format(value); + } + + function setTile(id: string, value: string, delta = '') { + root.querySelector(`[data-tile="${id}"]`)!.textContent = value; + root.querySelector(`[data-tile-delta="${id}"]`)!.textContent = delta; + } + + function renderTiles(history: SummaryResponse['history']) { + const first = history[0]; + const latest = history.at(-1); + if (!latest || !first) { + for (const id of ['instances', 'songs', 'edits', 'docker']) setTile(id, '–'); + return; + } + + const since = formatDate(first.day, true); + const delta = (now: number, then: number, format: (value: number) => string) => + history.length > 1 ? labels.vsStart.replace('{delta}', format(now - then)).replace('{date}', since) : ''; + + const { summary } = latest; + setTile( + 'instances', + integer.format(summary.instances), + delta(summary.instances, first.summary.instances, (value) => signed.format(value)), + ); + setTile( + 'songs', + compact.format(summary.totals.songs), + delta(summary.totals.songs, first.summary.totals.songs, (value) => + value === 0 ? '0' : `${value > 0 ? '+' : '−'}${compact.format(Math.abs(value))}`, + ), + ); + setTile('edits', integer.format(summary.totals.metadataEdits7d)); + + const { docker, bare } = summary.containerized; + setTile('docker', docker + bare ? `${percent.format((docker / (docker + bare)) * 100)} %` : '–'); + } + + function datasetsFor(spec: ChartSpec): ChartDataset<'line', number[]>[] { + const stacked = spec.kind !== 'line'; + return spec.series.map((series, index) => ({ + label: series.label, + data: series.values, + borderColor: stacked ? SURFACE : series.color, + backgroundColor: series.color, + // Stacked bands are separated by a 2px surface gap, not by a border in another color. + borderWidth: 2, + fill: stacked ? (index === 0 ? 'origin' : '-1') : false, + pointRadius: 0, + pointHoverRadius: stacked ? 0 : 4, + pointHoverBorderColor: SURFACE, + pointHoverBorderWidth: 2, + // Curved bands overshoot each other where a series drops to zero. + tension: stacked ? 0 : 0.25, + })); + } + + function renderChart(id: ChartId, spec: ChartSpec, dates: string[], granularity: SummaryResponse['granularity']) { + const withYear = granularity === 'week'; + const chartLabels = dates.map((day) => formatDate(day, withYear)); + const existing = charts.get(id); + + if (existing) { + existing.data.labels = chartLabels; + existing.data.datasets = datasetsFor(spec); + existing.options.scales!.y!.max = spec.kind === 'stacked-percent' ? 100 : undefined; + existing.update(); + return; + } + + const canvas = root.querySelector(`[data-chart="${id}"]`)!; + const stacked = spec.kind !== 'line'; + const isPercent = spec.kind === 'stacked-percent' || spec.percentAxis; + + const chart = new Chart<'line', number[], string>(canvas, { + type: 'line', + data: { labels: chartLabels, datasets: datasetsFor(spec) }, + plugins: [crosshair], + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: 'index', intersect: false }, + scales: { + x: { + grid: { display: false }, + border: { color: GRID }, + ticks: { color: MUTED, maxRotation: 0, autoSkipPadding: 24, font: { size: 11 } }, + }, + y: { + stacked, + beginAtZero: true, + max: spec.kind === 'stacked-percent' ? 100 : undefined, + grid: { color: GRID }, + border: { display: false }, + ticks: { + color: MUTED, + font: { size: 11 }, + maxTicksLimit: 5, + callback: (value) => (isPercent ? `${value} %` : compact.format(Number(value))), + }, + }, + }, + plugins: { + legend: { + // A single series is named by the chart title; two or more always get a legend. + display: spec.series.length > 1, + position: 'bottom', + labels: { + color: MUTED, + boxWidth: 10, + boxHeight: 10, + padding: 14, + font: { size: 12 }, + usePointStyle: !stacked, + pointStyle: 'line', + }, + }, + tooltip: { + backgroundColor: '#262626', + borderColor: 'rgba(255, 255, 255, 0.12)', + borderWidth: 1, + titleColor: MUTED, + bodyColor: FOREGROUND, + padding: 10, + boxWidth: 10, + boxHeight: 2, + usePointStyle: false, + // Top of the stack first, matching the order the bands are drawn in. + itemSort: stacked ? (a, b) => b.datasetIndex - a.datasetIndex : undefined, + callbacks: { + label: (item) => ` ${formatValue(Number(item.raw), lastSpecs![id])} ${item.dataset.label}`, + labelColor: (item) => { + const color = lastSpecs![id].series[item.datasetIndex]?.color ?? MUTED; + return { borderColor: color, backgroundColor: color }; + }, + }, + }, + }, + }, + }); + charts.set(id, chart); + } + + /** Table view: every value reachable without hovering. Built lazily, with textContent only. */ + function renderTable(details: HTMLDetailsElement) { + const id = details.dataset.table as ChartId; + const spec = lastSpecs?.[id]; + const container = details.querySelector('div')!; + container.replaceChildren(); + if (!spec || !details.open) return; + + const table = document.createElement('table'); + table.className = 'w-full text-left text-xs tabular-nums'; + const head = table.createTHead().insertRow(); + for (const text of [lastGranularity === 'week' ? labels.week : labels.date, ...spec.series.map((s) => s.label)]) { + const th = document.createElement('th'); + th.className = 'sticky top-0 bg-[var(--card)] py-1.5 pr-4 font-medium text-[var(--muted-foreground)]'; + th.textContent = text; + head.appendChild(th); + } + + const body = table.createTBody(); + // Newest first: the row people look for is the latest one. + for (let index = lastDates.length - 1; index >= 0; index--) { + const row = body.insertRow(); + row.className = 'border-t border-[var(--border)]'; + const dateCell = row.insertCell(); + dateCell.className = 'py-1.5 pr-4 text-[var(--muted-foreground)]'; + dateCell.textContent = formatDate(lastDates[index], true); + for (const series of spec.series) { + const cell = row.insertCell(); + cell.className = 'py-1.5 pr-4'; + cell.textContent = formatValue(series.values[index], spec); + } + } + container.appendChild(table); + } + + const tables = [...root.querySelectorAll('.js-table')]; + for (const details of tables) { + details.addEventListener('toggle', () => renderTable(details)); + } + + async function load(range: Range) { + for (const button of rangeButtons) { + button.setAttribute('aria-pressed', String(button.dataset.range === range)); + } + // Refetch keeps the frame: the previous render stays, dimmed, until the new data lands. + content.style.opacity = '0.5'; + + try { + const response = await fetch(`${api}/summary.json?range=${range}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = (await response.json()) as SummaryResponse; + + if (data.history.length === 0) { + status.textContent = labels.empty; + renderTiles([]); + return; + } + + status.textContent = data.updatedAt ? labels.updated.replace('{date}', formatDate(data.updatedAt, true)) : ''; + + lastSpecs = buildSpecs(data.history, labels); + lastDates = data.history.map((point) => point.day); + lastGranularity = data.granularity; + + renderTiles(data.history); + for (const [id, spec] of Object.entries(lastSpecs) as [ChartId, ChartSpec][]) { + renderChart(id, spec, lastDates, data.granularity); + } + for (const details of tables) renderTable(details); + } catch (error) { + console.error('Could not load Tagr usage statistics:', error); + status.textContent = labels.error; + } finally { + content.style.opacity = ''; + } + } + + const initial = new URLSearchParams(location.search).get('range'); + const startRange: Range = initial === '30d' || initial === '1y' || initial === 'all' ? initial : '90d'; + + for (const button of rangeButtons) { + button.addEventListener('click', () => { + const range = button.dataset.range as Range; + const url = new URL(location.href); + url.searchParams.set('range', range); + history.replaceState(null, '', url); + void load(range); + }); + } + + void load(startRange); +} diff --git a/landing/src/pages/analytics.astro b/landing/src/pages/analytics.astro new file mode 100644 index 00000000..b8ea5672 --- /dev/null +++ b/landing/src/pages/analytics.astro @@ -0,0 +1,18 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Analytics from '../components/Analytics.astro'; +import { t } from '../i18n'; + +const locale = 'en' as const; +const d = t(locale); +--- + + + + diff --git a/landing/src/pages/es/analytics.astro b/landing/src/pages/es/analytics.astro new file mode 100644 index 00000000..52299669 --- /dev/null +++ b/landing/src/pages/es/analytics.astro @@ -0,0 +1,18 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import Analytics from '../../components/Analytics.astro'; +import { t } from '../../i18n'; + +const locale = 'es' as const; +const d = t(locale); +--- + + + + diff --git a/messages/en.json b/messages/en.json index f67e9132..b2d473b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -282,10 +282,11 @@ }, "welcome": { "title": "Welcome to Tagr", - "description": "Tagr needs to scan your music folders to build a local database. This makes browsing and searching your library fast and smooth. No data leaves your machine.", + "description": "Tagr needs to scan your music folders to build a local database. This makes browsing and searching your library fast and smooth. Your music and its tags never leave your machine.", "timing": "This process may take a few minutes depending on the size of your library.", "scanButton": "Scan library", - "scanning": "Scanning your library..." + "scanning": "Scanning your library...", + "insightsNotice": "Tagr sends anonymous usage statistics once a day (no file names, paths or user names). You can turn this off in Settings." }, "version": { "updateAvailable": "Version {version} is available", @@ -510,7 +511,8 @@ "title": "Settings", "sections": { "users": "Users", - "integrations": "Third party integrations" + "integrations": "Third party integrations", + "insights": "Usage statistics" } }, "scrobbling": { @@ -532,5 +534,18 @@ "listens": { "title": "Recently played", "empty": "Nothing played yet" + }, + "insights": { + "title": "Anonymous usage statistics", + "description": "Once a day Tagr sends a small anonymous report (version, library size, which features are used) so we know how many people use it and what to improve. It never includes file names, paths, tags or user names.", + "enable": "Send anonymous usage statistics", + "forcedByEnv": "Disabled by the TAGR_INSIGHTS or DO_NOT_TRACK environment variable.", + "lastSent": "Last report sent on {date}.", + "neverSent": "No report sent yet.", + "dashboard": "The aggregated results are public: see the dashboard.", + "preview": "Show exactly what is sent", + "previewHelp": "This JSON is sent to {endpoint}:", + "enabledToast": "Usage statistics enabled", + "disabledToast": "Usage statistics disabled" } } diff --git a/prisma/migrations/20261008000000_add_app_properties/migration.sql b/prisma/migrations/20261008000000_add_app_properties/migration.sql new file mode 100644 index 00000000..7da8b72b --- /dev/null +++ b/prisma/migrations/20261008000000_add_app_properties/migration.sql @@ -0,0 +1,5 @@ +-- CreateTable +CREATE TABLE "app_properties" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e091a60e..6a3ca2da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -246,3 +246,11 @@ model ScrobbleQueueItem { @@index([accountId, nextAttemptAt]) @@map("scrobble_queue") } + +/// Instance-wide key/value settings, not tied to a user (e.g. the anonymous insights ID). +model AppProperty { + key String @id + value String + + @@map("app_properties") +} diff --git a/src/app/api/insights/route.ts b/src/app/api/insights/route.ts new file mode 100644 index 00000000..db8db5ba --- /dev/null +++ b/src/app/api/insights/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server' +import { getInsightsStatus, setInsightsEnabled } from '@/features/insights/insights.service' +import { requireRole } from '@/lib/api/auth-guard' + +export async function GET() { + const guard = await requireRole('admin') + if (!guard.authorized) return guard.response + + try { + const status = await getInsightsStatus() + + return NextResponse.json({ success: true, status }) + } catch (error) { + console.error('Error fetching insights status:', error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} + +export async function PUT(request: Request) { + const guard = await requireRole('admin') + if (!guard.authorized) return guard.response + + try { + const body = (await request.json()) as { enabled?: unknown } + + if (typeof body.enabled !== 'boolean') { + return NextResponse.json({ success: false, error: 'enabled must be a boolean' }, { status: 400 }) + } + + await setInsightsEnabled(body.enabled) + const status = await getInsightsStatus() + + return NextResponse.json({ success: true, status }) + } catch (error) { + console.error('Error updating insights setting:', error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/src/components/settings-modal/components/insights-settings.tsx b/src/components/settings-modal/components/insights-settings.tsx new file mode 100644 index 00000000..8b00de20 --- /dev/null +++ b/src/components/settings-modal/components/insights-settings.tsx @@ -0,0 +1,83 @@ +'use client' + +import { Loader2Icon } from 'lucide-react' +import { toast } from 'sonner' +import { useTranslations } from 'next-intl' +import Link from 'next/link' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { useInsights, useUpdateInsights } from '@/features/insights/hooks/use-insights' +import { formatDate, FULL_DATE_FORMAT } from '@/lib/date' + +const INSIGHTS_DASHBOARD_URL = 'https://tagr.xavirincon.com/analytics/' + +export function InsightsSettings() { + const t = useTranslations('insights') + const { data: status, isLoading } = useInsights() + const updateInsights = useUpdateInsights() + + const handleToggle = (enabled: boolean) => { + updateInsights.mutate(enabled, { + onSuccess: () => toast.success(enabled ? t('enabledToast') : t('disabledToast')), + onError: error => toast.error(error.message) + }) + } + + if (isLoading || !status) { + return ( +
+ +
+ ) + } + + return ( +
+
+

{t('title')}

+

{t('description')}

+
+ +
+ handleToggle(!!value)} + /> + +
+ + {status.forcedByEnv &&

{t('forcedByEnv')}

} + +

+ {status.lastSentAt + ? t('lastSent', { date: formatDate(status.lastSentAt, FULL_DATE_FORMAT) ?? '' }) + : t('neverSent')} +

+ +

+ {t.rich('dashboard', { + link: chunks => ( + + {chunks} + + ) + })} +

+ + + + {t('preview')} + +

{t('previewHelp', { endpoint: status.endpoint })}

+
+              {JSON.stringify(status.preview, null, 2)}
+            
+
+
+
+
+ ) +} diff --git a/src/components/settings-modal/settings-modal.tsx b/src/components/settings-modal/settings-modal.tsx index c57ee757..e23ba6c7 100644 --- a/src/components/settings-modal/settings-modal.tsx +++ b/src/components/settings-modal/settings-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { PlugIcon, SettingsIcon, UsersIcon, type LucideIcon } from 'lucide-react' +import { ChartNoAxesColumnIcon, PlugIcon, SettingsIcon, UsersIcon, type LucideIcon } from 'lucide-react' import { useState } from 'react' import { useSession } from 'next-auth/react' import { useTranslations } from 'next-intl' @@ -10,6 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import type { UserRole } from '@/features/users/domain' import { hasMinimumRole } from '@/features/users/lib/hasMinimumRole' import { useBreakpoint } from '@/hooks/use-breakpoint' +import { InsightsSettings } from './components/insights-settings' import { ListenBrainzSettings } from './components/listenbrainz-settings' import { UsersSettings } from './components/users-settings/users-settings' @@ -29,7 +30,8 @@ const SETTINGS_SECTIONS = [ icon: PlugIcon, minimumRole: 'listener', Content: ListenBrainzSettings - } + }, + { id: 'insights', labelKey: 'insights', icon: ChartNoAxesColumnIcon, minimumRole: 'admin', Content: InsightsSettings } ] as const satisfies readonly SettingsSectionDefinition[] export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]['id'] diff --git a/src/components/welcome-scan-state.tsx b/src/components/welcome-scan-state.tsx index a5f7bc85..8ec65e86 100644 --- a/src/components/welcome-scan-state.tsx +++ b/src/components/welcome-scan-state.tsx @@ -38,6 +38,7 @@ export function WelcomeScanState() { {isPending ? : } {isPending ? t('scanning') : t('scanButton')} +

{t('insightsNotice')}

diff --git a/src/features/insights/consts.ts b/src/features/insights/consts.ts new file mode 100644 index 00000000..c56b95fb --- /dev/null +++ b/src/features/insights/consts.ts @@ -0,0 +1,11 @@ +/** Every published version reports here, so it lives on a domain we control rather than workers.dev. */ +export const INSIGHTS_ENDPOINT = 'https://analytics.tagr.xavirincon.com/collect' + +/** Same cadence as Navidrome: skip short-lived restarts, then once a day. */ +export const INSIGHTS_INITIAL_DELAY_MS = 30 * 60 * 1000 +export const INSIGHTS_INTERVAL_MS = 24 * 60 * 60 * 1000 +export const INSIGHTS_REQUEST_TIMEOUT_MS = 10 * 1000 + +export const INSIGHTS_ID_KEY = 'insightsId' +export const INSIGHTS_ENABLED_KEY = 'insightsEnabled' +export const INSIGHTS_LAST_SENT_KEY = 'insightsLastSentAt' diff --git a/src/features/insights/domain.ts b/src/features/insights/domain.ts new file mode 100644 index 00000000..de7b5eec --- /dev/null +++ b/src/features/insights/domain.ts @@ -0,0 +1,52 @@ +/** + * Anonymous usage report an instance sends once a day to the Tagr Insights server + * (github.com/suitux/tagr-insights). Only counts and flags: never paths, titles or usernames. + * + * Bump `schema` whenever a field changes meaning, so the server can tell old reports apart. + */ +export interface InsightsData { + schema: 1 + id: string + version: string + /** Seconds since the server process started */ + uptime: number + os: { + platform: string + arch: string + containerized: boolean + nodeVersion: string + } + library: { + songs: number + musicFolders: number + /** Song count per lowercase file extension, e.g. { flac: 1200, mp3: 300 } */ + fileExtensions: Record + } + users: { + total: number + /** Distinct users who played something in the last 7 days */ + listeners7d: number + } + usage7d: { + metadataEdits: number + editedSongs: number + listens: number + } + features: { + smartPlaylists: number + savedFilters: number + sharedLinks: number + /** Enabled scrobbling accounts per provider */ + scrobbleAccounts: Record + } +} + +export interface InsightsStatus { + enabled: boolean + /** Set when an env var decides, so the Settings toggle can't override it */ + forcedByEnv: boolean + endpoint: string + lastSentAt: string | null + /** Exactly what the next report would contain */ + preview: InsightsData +} diff --git a/src/features/insights/hooks/use-insights.ts b/src/features/insights/hooks/use-insights.ts new file mode 100644 index 00000000..31daae2f --- /dev/null +++ b/src/features/insights/hooks/use-insights.ts @@ -0,0 +1,29 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/axios' +import type { InsightsStatus } from '../domain' + +const INSIGHTS_QUERY_KEY = ['insights'] + +export function useInsights() { + return useQuery({ + queryKey: INSIGHTS_QUERY_KEY, + queryFn: async () => { + const { data } = await api.get<{ success: true; status: InsightsStatus }>('/insights') + return data.status + } + }) +} + +export function useUpdateInsights() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (enabled: boolean) => { + const { data } = await api.put<{ success: true; status: InsightsStatus }>('/insights', { enabled }) + return data.status + }, + onSuccess: status => { + queryClient.setQueryData(INSIGHTS_QUERY_KEY, status) + } + }) +} diff --git a/src/features/insights/insights-scheduler.ts b/src/features/insights/insights-scheduler.ts new file mode 100644 index 00000000..a5dc4f7f --- /dev/null +++ b/src/features/insights/insights-scheduler.ts @@ -0,0 +1,21 @@ +import { INSIGHTS_INITIAL_DELAY_MS, INSIGHTS_INTERVAL_MS } from './consts' +import { sendInsights } from './insights.service' + +const globalForInsights = globalThis as unknown as { insightsSchedulerStarted?: boolean } + +/** + * Starts the daily insights report. Idempotent: register() can run more than once per process. + * TAGR_INSIGHTS_DEBUG=1 sends the first report right away, for testing against a local server. + */ +export function startInsightsScheduler(): void { + if (globalForInsights.insightsSchedulerStarted) return + globalForInsights.insightsSchedulerStarted = true + + const initialDelay = process.env.TAGR_INSIGHTS_DEBUG === '1' ? 0 : INSIGHTS_INITIAL_DELAY_MS + + const initialTimer = setTimeout(() => { + void sendInsights() + setInterval(() => void sendInsights(), INSIGHTS_INTERVAL_MS).unref() + }, initialDelay) + initialTimer.unref() +} diff --git a/src/features/insights/insights.repository.ts b/src/features/insights/insights.repository.ts new file mode 100644 index 00000000..147e71a8 --- /dev/null +++ b/src/features/insights/insights.repository.ts @@ -0,0 +1,69 @@ +import { prisma } from '@/infrastructure/prisma/dbClient' + +export async function getAppProperty(key: string): Promise { + const row = await prisma.appProperty.findUnique({ where: { key } }) + return row?.value ?? null +} + +export async function setAppProperty(key: string, value: string): Promise { + await prisma.appProperty.upsert({ + where: { key }, + create: { key, value }, + update: { value } + }) +} + +export async function countSongsByExtension(): Promise> { + const rows = await prisma.song.groupBy({ by: ['extension'], _count: { _all: true } }) + + const result: Record = {} + for (const row of rows) { + const extension = row.extension.replace(/^\./, '').toLowerCase() + result[extension] = (result[extension] ?? 0) + row._count._all + } + return result +} + +export async function countRecentActivity(since: Date) { + const [metadataEdits, editedSongs, listens, listeners] = await Promise.all([ + prisma.songChangeHistory.count({ where: { changedAt: { gte: since } } }), + prisma.songChangeHistory.findMany({ + where: { changedAt: { gte: since } }, + distinct: ['songId'], + select: { songId: true } + }), + prisma.listen.count({ where: { listenedAt: { gte: since } } }), + prisma.listen.findMany({ + where: { listenedAt: { gte: since } }, + distinct: ['userId'], + select: { userId: true } + }) + ]) + + return { + metadataEdits, + editedSongs: editedSongs.length, + listens, + listeners: listeners.length + } +} + +export async function countFeatureUsage() { + const [songs, users, smartPlaylists, savedFilters, sharedLinks, scrobbleAccounts] = await Promise.all([ + prisma.song.count(), + prisma.user.count(), + prisma.smartPlaylist.count(), + prisma.savedFilter.count(), + prisma.sharedLink.count({ where: { expiresAt: { gt: new Date() } } }), + prisma.scrobbleAccount.groupBy({ by: ['provider'], where: { enabled: true }, _count: { _all: true } }) + ]) + + return { + songs, + users, + smartPlaylists, + savedFilters, + sharedLinks, + scrobbleAccounts: Object.fromEntries(scrobbleAccounts.map(row => [row.provider, row._count._all])) + } +} diff --git a/src/features/insights/insights.service.test.ts b/src/features/insights/insights.service.test.ts new file mode 100644 index 00000000..0f7bf9a7 --- /dev/null +++ b/src/features/insights/insights.service.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { collectInsights, getEnvInsightsOverride, isInsightsEnabled, sendInsights } from './insights.service' + +const { mockGetAppProperty, mockSetAppProperty } = vi.hoisted(() => ({ + mockGetAppProperty: vi.fn(), + mockSetAppProperty: vi.fn() +})) + +vi.mock('./insights.repository', () => ({ + getAppProperty: (...args: unknown[]) => mockGetAppProperty(...args), + setAppProperty: (...args: unknown[]) => mockSetAppProperty(...args), + countSongsByExtension: async () => ({ flac: 10, mp3: 5 }), + countRecentActivity: async () => ({ metadataEdits: 7, editedSongs: 3, listens: 20, listeners: 2 }), + countFeatureUsage: async () => ({ + songs: 15, + users: 1, + smartPlaylists: 2, + savedFilters: 0, + sharedLinks: 1, + scrobbleAccounts: { listenbrainz: 1 } + }) +})) + +vi.mock('@/features/songs/song-file-helpers', () => ({ + getMusicFolders: () => ['/music/a', '/music/b'] +})) + +describe('insights.service', () => { + beforeEach(() => { + mockGetAppProperty.mockReset() + mockSetAppProperty.mockReset() + vi.unstubAllEnvs() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('getEnvInsightsOverride', () => { + it('is null when no env var is set', () => { + vi.stubEnv('TAGR_INSIGHTS', '') + vi.stubEnv('DO_NOT_TRACK', '') + expect(getEnvInsightsOverride()).toBeNull() + }) + + it.each([ + ['TAGR_INSIGHTS', 'false'], + ['TAGR_INSIGHTS', 'off'], + ['DO_NOT_TRACK', '1'], + ['DO_NOT_TRACK', 'true'] + ])('disables with %s=%s', (name, value) => { + vi.stubEnv(name, value) + expect(getEnvInsightsOverride()).toBe(false) + }) + }) + + describe('isInsightsEnabled', () => { + it('is enabled by default', async () => { + mockGetAppProperty.mockResolvedValue(null) + expect(await isInsightsEnabled()).toBe(true) + }) + + it('respects the Settings toggle', async () => { + mockGetAppProperty.mockResolvedValue('false') + expect(await isInsightsEnabled()).toBe(false) + }) + + it('lets the env var win over the toggle', async () => { + vi.stubEnv('DO_NOT_TRACK', '1') + mockGetAppProperty.mockResolvedValue('true') + expect(await isInsightsEnabled()).toBe(false) + }) + }) + + describe('collectInsights', () => { + it('reuses the stored instance id', async () => { + mockGetAppProperty.mockResolvedValue('existing-id') + + const data = await collectInsights() + + expect(data.id).toBe('existing-id') + expect(mockSetAppProperty).not.toHaveBeenCalled() + }) + + it('creates and stores an id on first run', async () => { + mockGetAppProperty.mockResolvedValue(null) + + const data = await collectInsights() + + expect(data.id).toMatch(/^[0-9a-f-]{36}$/) + expect(mockSetAppProperty).toHaveBeenCalledWith('insightsId', data.id) + }) + + it('reports counts only, adding the env admin to the user total', async () => { + mockGetAppProperty.mockResolvedValue('id') + + const data = await collectInsights() + + expect(data).toMatchObject({ + schema: 1, + library: { songs: 15, musicFolders: 2, fileExtensions: { flac: 10, mp3: 5 } }, + users: { total: 2, listeners7d: 2 }, + usage7d: { metadataEdits: 7, editedSongs: 3, listens: 20 }, + features: { smartPlaylists: 2, savedFilters: 0, sharedLinks: 1, scrobbleAccounts: { listenbrainz: 1 } } + }) + }) + }) + + describe('sendInsights', () => { + it('does not call the network when disabled', async () => { + vi.stubEnv('TAGR_INSIGHTS', 'false') + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await sendInsights() + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('posts the report and records when it was sent', async () => { + vi.stubEnv('TAGR_INSIGHTS_ENDPOINT', 'http://localhost:8787/collect') + mockGetAppProperty.mockResolvedValue('id') + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 }) + vi.stubGlobal('fetch', fetchMock) + + await sendInsights() + + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8787/collect', expect.objectContaining({ method: 'POST' })) + expect(mockSetAppProperty).toHaveBeenCalledWith('insightsLastSentAt', expect.any(String)) + }) + + it('swallows network errors', async () => { + mockGetAppProperty.mockResolvedValue('id') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + + await expect(sendInsights()).resolves.toBeUndefined() + expect(mockSetAppProperty).not.toHaveBeenCalledWith('insightsLastSentAt', expect.anything()) + }) + }) +}) diff --git a/src/features/insights/insights.service.ts b/src/features/insights/insights.service.ts new file mode 100644 index 00000000..928e5c5d --- /dev/null +++ b/src/features/insights/insights.service.ts @@ -0,0 +1,145 @@ +import { randomUUID } from 'crypto' +import { existsSync } from 'fs' +import { getMusicFolders } from '@/features/songs/song-file-helpers' +import { SEVEN_DAYS_MS } from '@/lib/date' +import { + INSIGHTS_ENABLED_KEY, + INSIGHTS_ENDPOINT, + INSIGHTS_ID_KEY, + INSIGHTS_LAST_SENT_KEY, + INSIGHTS_REQUEST_TIMEOUT_MS +} from './consts' +import type { InsightsData, InsightsStatus } from './domain' +import { + countFeatureUsage, + countRecentActivity, + countSongsByExtension, + getAppProperty, + setAppProperty +} from './insights.repository' + +export function getInsightsEndpoint(): string { + return process.env.TAGR_INSIGHTS_ENDPOINT || INSIGHTS_ENDPOINT +} + +/** An env var wins over the Settings toggle: `false` from either TAGR_INSIGHTS or DO_NOT_TRACK. */ +export function getEnvInsightsOverride(): boolean | null { + const flag = process.env.TAGR_INSIGHTS?.trim().toLowerCase() + if (flag === 'false' || flag === '0' || flag === 'off') return false + + const doNotTrack = process.env.DO_NOT_TRACK?.trim().toLowerCase() + if (doNotTrack === '1' || doNotTrack === 'true') return false + + return null +} + +export async function isInsightsEnabled(): Promise { + const override = getEnvInsightsOverride() + if (override !== null) return override + + return (await getAppProperty(INSIGHTS_ENABLED_KEY)) !== 'false' +} + +export async function setInsightsEnabled(enabled: boolean): Promise { + await setAppProperty(INSIGHTS_ENABLED_KEY, String(enabled)) +} + +async function getOrCreateInsightsId(): Promise { + const existing = await getAppProperty(INSIGHTS_ID_KEY) + if (existing) return existing + + const id = randomUUID() + await setAppProperty(INSIGHTS_ID_KEY, id) + return id +} + +function isContainerized(): boolean { + return existsSync('/.dockerenv') || existsSync('/run/.containerenv') +} + +export async function collectInsights(): Promise { + const since = new Date(Date.now() - SEVEN_DAYS_MS) + + const [id, fileExtensions, activity, usage] = await Promise.all([ + getOrCreateInsightsId(), + countSongsByExtension(), + countRecentActivity(since), + countFeatureUsage() + ]) + + return { + schema: 1, + id, + version: process.env.APP_VERSION ?? '0.0.0', + uptime: Math.round(process.uptime()), + os: { + platform: process.platform, + arch: process.arch, + containerized: isContainerized(), + nodeVersion: process.versions.node + }, + library: { + songs: usage.songs, + musicFolders: getMusicFolders().length, + fileExtensions + }, + users: { + // The AUTH_USER admin lives in env, not in the users table + total: usage.users + 1, + listeners7d: activity.listeners + }, + usage7d: { + metadataEdits: activity.metadataEdits, + editedSongs: activity.editedSongs, + listens: activity.listens + }, + features: { + smartPlaylists: usage.smartPlaylists, + savedFilters: usage.savedFilters, + sharedLinks: usage.sharedLinks, + scrobbleAccounts: usage.scrobbleAccounts + } + } +} + +/** One attempt, no retries: a lost report only costs one day of one instance. */ +export async function sendInsights(): Promise { + if (!(await isInsightsEnabled())) return + + const endpoint = getInsightsEndpoint() + + try { + const data = await collectInsights() + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + signal: AbortSignal.timeout(INSIGHTS_REQUEST_TIMEOUT_MS) + }) + + if (!response.ok) { + console.warn(`Insights report rejected by ${endpoint}: ${response.status}`) + return + } + + await setAppProperty(INSIGHTS_LAST_SENT_KEY, new Date().toISOString()) + } catch (error) { + console.warn(`Could not send insights report to ${endpoint}:`, error instanceof Error ? error.message : error) + } +} + +export async function getInsightsStatus(): Promise { + const [enabled, lastSentAt, preview] = await Promise.all([ + isInsightsEnabled(), + getAppProperty(INSIGHTS_LAST_SENT_KEY), + collectInsights() + ]) + + return { + enabled, + forcedByEnv: getEnvInsightsOverride() !== null, + endpoint: getInsightsEndpoint(), + lastSentAt, + preview + } +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 00000000..5991922a --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,7 @@ +export async function register() { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + if (process.env.NODE_ENV !== 'production' && process.env.TAGR_INSIGHTS_DEBUG !== '1') return + + const { startInsightsScheduler } = await import('@/features/insights/insights-scheduler') + startInsightsScheduler() +} diff --git a/src/lib/date.ts b/src/lib/date.ts index b16d2245..0449d886 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -3,6 +3,7 @@ import { format, formatDuration, intervalToDuration, isSameDay, isValid, parse, export const ISO_DATE_FORMAT = 'yyyy-MM-dd' export const DISPLAY_DATE_FORMAT = 'MMM d' export const FULL_DATE_FORMAT = 'dd MMMM yyyy, HH:mm' +export const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 export function formatDate(date: Date | string | null, fmt: string = ISO_DATE_FORMAT): string | null { if (!date) return null From 1894fee93afa9dc3126674e8eef02ca4c2dd9b0a Mon Sep 17 00:00:00 2001 From: Xavier Rincon Date: Thu, 8 Oct 2026 17:50:12 +0200 Subject: [PATCH 2/5] Added dialog and warning --- README.md | 8 +- messages/en.json | 13 +- src/app/(app)/app-shell.tsx | 2 + src/app/api/insights/notice/route.ts | 21 ++++ src/components/insights-notice-dialog.tsx | 111 ++++++++++++++++++ .../components/insights-settings.tsx | 10 ++ src/features/insights/consts.ts | 2 + src/features/insights/domain.ts | 7 ++ src/features/insights/hooks/use-insights.ts | 22 +++- src/features/insights/insights-scheduler.ts | 4 +- .../insights/insights.service.test.ts | 54 ++++++++- src/features/insights/insights.service.ts | 52 +++++++- src/instrumentation.ts | 2 + 13 files changed, 297 insertions(+), 11 deletions(-) create mode 100644 src/app/api/insights/notice/route.ts create mode 100644 src/components/insights-notice-dialog.tsx diff --git a/README.md b/README.md index c015c5fb..31d1866a 100644 --- a/README.md +++ b/README.md @@ -295,8 +295,12 @@ The report contains only counts and flags: - How many smart playlists, saved filters, active share links and scrobbling accounts exist It never includes file names, paths, tags, user names, tokens or your IP address (the server does not store it). -The first report goes out 30 minutes after start-up, then every 24 hours. Admins can see the exact JSON under -**Settings → Usage statistics**. + +**Nothing is sent until an admin has seen the notice.** After installing or upgrading, admins get a popup +explaining what is sent, with **Keep enabled** and **Disable** buttons. Until one of them is clicked (or +the setting is changed in **Settings → Usage statistics**, which also shows the exact JSON), no report leaves the +server. After that, a report goes out 30 minutes after start-up and then every 24 hours. The container log also +says at start-up whether statistics are on. To opt out, untick it in **Settings → Usage statistics**, or set `TAGR_INSIGHTS=false` (or `DO_NOT_TRACK=1`) to turn it off for good. The collector is open source: [suitux/tagr-insights](https://github.com/suitux/tagr-insights). diff --git a/messages/en.json b/messages/en.json index b2d473b4..4075ffa4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -546,6 +546,17 @@ "preview": "Show exactly what is sent", "previewHelp": "This JSON is sent to {endpoint}:", "enabledToast": "Usage statistics enabled", - "disabledToast": "Usage statistics disabled" + "disabledToast": "Usage statistics disabled", + "noticeTitle": "Help improve Tagr with anonymous usage statistics", + "noticeBody": "Tagr can send a small anonymous report once a day so we know how many people use it and which features are worth working on. Nothing has been sent yet, and nothing will be unless you keep it enabled.", + "noticeDetails": "Show the exact report", + "noticeKeep": "Keep enabled", + "noticeDisable": "Disable", + "pendingNotice": "Nothing has been sent yet: it starts once an admin confirms.", + "noticeSentTitle": "What is sent", + "noticeSent": "Tagr version, OS and architecture, number of songs and users, file formats, and how often features like tag editing, smart playlists or scrobbling are used.", + "noticeNeverTitle": "What is never sent", + "noticeNever": "File names, paths, tags, user names, tokens or your IP address.", + "noticeLater": "You can change this at any time in Settings → Usage statistics." } } diff --git a/src/app/(app)/app-shell.tsx b/src/app/(app)/app-shell.tsx index ff65b5b6..30424401 100644 --- a/src/app/(app)/app-shell.tsx +++ b/src/app/(app)/app-shell.tsx @@ -6,6 +6,7 @@ import { BulkSummaryModal } from '@/components/bulk-summary-modal' import { ResponsiveLayout } from '@/components/layout/responsive-layout' import { DetailPanel } from '@/components/panels/detail-panel/detail-panel' import { FolderList } from '@/components/panels/folder-list/folder-list' +import { InsightsNoticeDialog } from '@/components/insights-notice-dialog' import { ScanSummaryModal } from '@/components/scan-summary-modal' import { StarPromptDialog } from '@/components/star-prompt-dialog' import { useLibraryNavigation } from '@/hooks/use-library-navigation' @@ -40,6 +41,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { + { + updateInsights.mutate(enabled, { + onSuccess: () => toast.success(enabled ? t('enabledToast') : t('disabledToast')), + onError: error => toast.error(error.message) + }) + } + + return ( + !value && setClosedForNow(true)}> + + + + + {t('noticeTitle')} + + {t('noticeBody')} + + +
+
+

{t('noticeSentTitle')}

+

{t('noticeSent')}

+
+
+

{t('noticeNeverTitle')}

+

{t('noticeNever')}

+
+

+ {t.rich('dashboard', { + link: chunks => ( + + {chunks} + + ) + })}{' '} + {t('noticeLater')} +

+ + {/* Not an Accordion: it locks its height when opened, before the report has loaded */} +
+ + {detailsOpen && + (isPreviewLoading || !status ? ( +
+ +
+ ) : ( +
+                  {JSON.stringify(status.preview, null, 2)}
+                
+ ))} +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/settings-modal/components/insights-settings.tsx b/src/components/settings-modal/components/insights-settings.tsx index 8b00de20..c7722e3f 100644 --- a/src/components/settings-modal/components/insights-settings.tsx +++ b/src/components/settings-modal/components/insights-settings.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { useTranslations } from 'next-intl' import Link from 'next/link' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' import { useInsights, useUpdateInsights } from '@/features/insights/hooks/use-insights' @@ -51,6 +52,15 @@ export function InsightsSettings() { {status.forcedByEnv &&

{t('forcedByEnv')}

} + {status.enabled && !status.forcedByEnv && !status.noticeAcknowledged && ( +
+

{t('pendingNotice')}

+ +
+ )} +

{status.lastSentAt ? t('lastSent', { date: formatDate(status.lastSentAt, FULL_DATE_FORMAT) ?? '' }) diff --git a/src/features/insights/consts.ts b/src/features/insights/consts.ts index c56b95fb..dbb9b265 100644 --- a/src/features/insights/consts.ts +++ b/src/features/insights/consts.ts @@ -9,3 +9,5 @@ export const INSIGHTS_REQUEST_TIMEOUT_MS = 10 * 1000 export const INSIGHTS_ID_KEY = 'insightsId' export const INSIGHTS_ENABLED_KEY = 'insightsEnabled' export const INSIGHTS_LAST_SENT_KEY = 'insightsLastSentAt' +/** Set once an admin has answered the in-app notice. Nothing is sent before that. */ +export const INSIGHTS_NOTICE_SEEN_KEY = 'insightsNoticeSeenAt' diff --git a/src/features/insights/domain.ts b/src/features/insights/domain.ts index de7b5eec..24f2b768 100644 --- a/src/features/insights/domain.ts +++ b/src/features/insights/domain.ts @@ -47,6 +47,13 @@ export interface InsightsStatus { forcedByEnv: boolean endpoint: string lastSentAt: string | null + /** False until an admin has answered the in-app notice; nothing is sent before that */ + noticeAcknowledged: boolean /** Exactly what the next report would contain */ preview: InsightsData } + +export interface InsightsNoticeState { + /** The admin still has to be told that reports are sent */ + pending: boolean +} diff --git a/src/features/insights/hooks/use-insights.ts b/src/features/insights/hooks/use-insights.ts index 31daae2f..475398d9 100644 --- a/src/features/insights/hooks/use-insights.ts +++ b/src/features/insights/hooks/use-insights.ts @@ -1,16 +1,30 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '@/lib/axios' -import type { InsightsStatus } from '../domain' +import type { InsightsNoticeState, InsightsStatus } from '../domain' const INSIGHTS_QUERY_KEY = ['insights'] +const INSIGHTS_NOTICE_QUERY_KEY = ['insights', 'notice'] -export function useInsights() { +export function useInsights(enabled = true) { return useQuery({ queryKey: INSIGHTS_QUERY_KEY, queryFn: async () => { const { data } = await api.get<{ success: true; status: InsightsStatus }>('/insights') return data.status - } + }, + enabled + }) +} + +export function useInsightsNotice(enabled: boolean) { + return useQuery({ + queryKey: INSIGHTS_NOTICE_QUERY_KEY, + queryFn: async () => { + const { data } = await api.get<{ success: true; notice: InsightsNoticeState }>('/insights/notice') + return data.notice + }, + enabled, + staleTime: Infinity }) } @@ -24,6 +38,8 @@ export function useUpdateInsights() { }, onSuccess: status => { queryClient.setQueryData(INSIGHTS_QUERY_KEY, status) + // Any answer, from the banner or from Settings, settles the notice + queryClient.setQueryData(INSIGHTS_NOTICE_QUERY_KEY, { pending: false }) } }) } diff --git a/src/features/insights/insights-scheduler.ts b/src/features/insights/insights-scheduler.ts index a5dc4f7f..bdece69c 100644 --- a/src/features/insights/insights-scheduler.ts +++ b/src/features/insights/insights-scheduler.ts @@ -1,5 +1,5 @@ import { INSIGHTS_INITIAL_DELAY_MS, INSIGHTS_INTERVAL_MS } from './consts' -import { sendInsights } from './insights.service' +import { logInsightsStartupState, sendInsights } from './insights.service' const globalForInsights = globalThis as unknown as { insightsSchedulerStarted?: boolean } @@ -11,6 +11,8 @@ export function startInsightsScheduler(): void { if (globalForInsights.insightsSchedulerStarted) return globalForInsights.insightsSchedulerStarted = true + void logInsightsStartupState() + const initialDelay = process.env.TAGR_INSIGHTS_DEBUG === '1' ? 0 : INSIGHTS_INITIAL_DELAY_MS const initialTimer = setTimeout(() => { diff --git a/src/features/insights/insights.service.test.ts b/src/features/insights/insights.service.test.ts index 0f7bf9a7..9be56e4a 100644 --- a/src/features/insights/insights.service.test.ts +++ b/src/features/insights/insights.service.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { collectInsights, getEnvInsightsOverride, isInsightsEnabled, sendInsights } from './insights.service' +import { + collectInsights, + getEnvInsightsOverride, + getInsightsNoticeState, + isInsightsEnabled, + sendInsights, + setInsightsEnabled +} from './insights.service' const { mockGetAppProperty, mockSetAppProperty } = vi.hoisted(() => ({ mockGetAppProperty: vi.fn(), @@ -129,6 +136,16 @@ describe('insights.service', () => { expect(mockSetAppProperty).toHaveBeenCalledWith('insightsLastSentAt', expect.any(String)) }) + it('holds reports back until an admin has seen the notice', async () => { + mockGetAppProperty.mockImplementation(async (key: string) => (key === 'insightsNoticeSeenAt' ? null : 'id')) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await sendInsights() + + expect(fetchMock).not.toHaveBeenCalled() + }) + it('swallows network errors', async () => { mockGetAppProperty.mockResolvedValue('id') vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) @@ -137,4 +154,39 @@ describe('insights.service', () => { expect(mockSetAppProperty).not.toHaveBeenCalledWith('insightsLastSentAt', expect.anything()) }) }) + + describe('notice', () => { + it('is pending until the admin answers', async () => { + mockGetAppProperty.mockResolvedValue(null) + expect(await getInsightsNoticeState()).toEqual({ pending: true }) + }) + + it('is settled once answered', async () => { + mockGetAppProperty.mockResolvedValue('2026-10-08T00:00:00.000Z') + expect(await getInsightsNoticeState()).toEqual({ pending: false }) + }) + + it('is not needed when an env var already opted out', async () => { + vi.stubEnv('TAGR_INSIGHTS', 'false') + mockGetAppProperty.mockResolvedValue(null) + expect(await getInsightsNoticeState()).toEqual({ pending: false }) + }) + + it('records the answer together with the choice', async () => { + mockGetAppProperty.mockResolvedValue(null) + + await setInsightsEnabled(false) + + expect(mockSetAppProperty).toHaveBeenCalledWith('insightsEnabled', 'false') + expect(mockSetAppProperty).toHaveBeenCalledWith('insightsNoticeSeenAt', expect.any(String)) + }) + + it('keeps the first answer date when toggled again', async () => { + mockGetAppProperty.mockResolvedValue('2026-10-08T00:00:00.000Z') + + await setInsightsEnabled(true) + + expect(mockSetAppProperty).not.toHaveBeenCalledWith('insightsNoticeSeenAt', expect.anything()) + }) + }) }) diff --git a/src/features/insights/insights.service.ts b/src/features/insights/insights.service.ts index 928e5c5d..f1cdcc4d 100644 --- a/src/features/insights/insights.service.ts +++ b/src/features/insights/insights.service.ts @@ -7,9 +7,10 @@ import { INSIGHTS_ENDPOINT, INSIGHTS_ID_KEY, INSIGHTS_LAST_SENT_KEY, + INSIGHTS_NOTICE_SEEN_KEY, INSIGHTS_REQUEST_TIMEOUT_MS } from './consts' -import type { InsightsData, InsightsStatus } from './domain' +import type { InsightsData, InsightsNoticeState, InsightsStatus } from './domain' import { countFeatureUsage, countRecentActivity, @@ -40,8 +41,22 @@ export async function isInsightsEnabled(): Promise { return (await getAppProperty(INSIGHTS_ENABLED_KEY)) !== 'false' } +export async function isInsightsNoticeAcknowledged(): Promise { + return (await getAppProperty(INSIGHTS_NOTICE_SEEN_KEY)) !== null +} + +/** Answering the notice or touching the Settings toggle both count as the admin having been told. */ export async function setInsightsEnabled(enabled: boolean): Promise { await setAppProperty(INSIGHTS_ENABLED_KEY, String(enabled)) + if (!(await isInsightsNoticeAcknowledged())) { + await setAppProperty(INSIGHTS_NOTICE_SEEN_KEY, new Date().toISOString()) + } +} + +/** An env opt-out needs no notice: the admin already decided. */ +export async function getInsightsNoticeState(): Promise { + if (getEnvInsightsOverride() === false) return { pending: false } + return { pending: !(await isInsightsNoticeAcknowledged()) } } async function getOrCreateInsightsId(): Promise { @@ -102,9 +117,14 @@ export async function collectInsights(): Promise { } } -/** One attempt, no retries: a lost report only costs one day of one instance. */ +/** + * One attempt, no retries: a lost report only costs one day of one instance. + * Nothing leaves the server until an admin has seen the in-app notice, so an upgrade never + * starts reporting behind anyone's back. + */ export async function sendInsights(): Promise { if (!(await isInsightsEnabled())) return + if (!(await isInsightsNoticeAcknowledged())) return const endpoint = getInsightsEndpoint() @@ -129,9 +149,10 @@ export async function sendInsights(): Promise { } export async function getInsightsStatus(): Promise { - const [enabled, lastSentAt, preview] = await Promise.all([ + const [enabled, lastSentAt, noticeAcknowledged, preview] = await Promise.all([ isInsightsEnabled(), getAppProperty(INSIGHTS_LAST_SENT_KEY), + isInsightsNoticeAcknowledged(), collectInsights() ]) @@ -140,6 +161,31 @@ export async function getInsightsStatus(): Promise { forcedByEnv: getEnvInsightsOverride() !== null, endpoint: getInsightsEndpoint(), lastSentAt, + noticeAcknowledged, preview } } + +/** One line in the container logs, for admins who read those rather than the UI. */ +export async function logInsightsStartupState(): Promise { + try { + if (getEnvInsightsOverride() === false) { + console.info('Anonymous usage statistics are disabled by TAGR_INSIGHTS / DO_NOT_TRACK.') + return + } + if (!(await isInsightsEnabled())) { + console.info('Anonymous usage statistics are disabled in Settings.') + return + } + const base = + 'Tagr sends anonymous usage statistics once a day (counts only, see the README). ' + + 'Turn them off in Settings → Usage statistics or with TAGR_INSIGHTS=false.' + console.info( + (await isInsightsNoticeAcknowledged()) + ? base + : `${base} Nothing is sent until an admin has seen the notice in the web UI.` + ) + } catch (error) { + console.warn('Could not read the usage statistics setting:', error instanceof Error ? error.message : error) + } +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 5991922a..b54903e4 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,5 +1,7 @@ export async function register() { if (process.env.NEXT_RUNTIME !== 'nodejs') return + // `next build` loads instrumentation too, and there is no database to read there + if (process.env.NEXT_PHASE === 'phase-production-build') return if (process.env.NODE_ENV !== 'production' && process.env.TAGR_INSIGHTS_DEBUG !== '1') return const { startInsightsScheduler } = await import('@/features/insights/insights-scheduler') From f1cb25e1623c544899ce13cfad210c53d1d2e1c4 Mon Sep 17 00:00:00 2001 From: Xavier Rincon Date: Thu, 8 Oct 2026 18:07:05 +0200 Subject: [PATCH 3/5] Added send now --- README.md | 2 +- landing/src/i18n/index.ts | 2 +- messages/en.json | 5 ++- src/app/api/insights/send/route.ts | 30 +++++++++++++++++ .../components/insights-settings.tsx | 32 +++++++++++++++---- src/features/insights/consts.ts | 2 +- src/features/insights/domain.ts | 4 +++ src/features/insights/hooks/use-insights.ts | 14 ++++++++ .../insights/insights.service.test.ts | 12 +++++-- src/features/insights/insights.service.ts | 17 ++++++---- 10 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 src/app/api/insights/send/route.ts diff --git a/README.md b/README.md index 31d1866a..ef7d19ef 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ and play counts inside Tagr are recorded regardless of whether scrobbling is con ## Anonymous usage statistics -Once a day, Tagr sends a small anonymous report to `https://analytics.tagr.xavirincon.com/collect` so we can tell +Once a day, Tagr sends a small anonymous report to `https://tagr-analytics.xavirincon.com/collect` so we can tell how many instances exist and which features are worth working on. The aggregated results are public at . diff --git a/landing/src/i18n/index.ts b/landing/src/i18n/index.ts index e448620c..b0057fb0 100644 --- a/landing/src/i18n/index.ts +++ b/landing/src/i18n/index.ts @@ -40,6 +40,6 @@ export const LINKS = { image: 'ghcr.io/suitux/tagr:latest', version: '1.8.6', license: 'https://www.gnu.org/licenses/agpl-3.0.html', - insightsApi: 'https://analytics.tagr.xavirincon.com', + insightsApi: 'https://tagr-analytics.xavirincon.com', insightsDocs: 'https://github.com/suitux/Tagr#anonymous-usage-statistics', } as const; diff --git a/messages/en.json b/messages/en.json index 4075ffa4..8de436f6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -557,6 +557,9 @@ "noticeSent": "Tagr version, OS and architecture, number of songs and users, file formats, and how often features like tag editing, smart playlists or scrobbling are used.", "noticeNeverTitle": "What is never sent", "noticeNever": "File names, paths, tags, user names, tokens or your IP address.", - "noticeLater": "You can change this at any time in Settings → Usage statistics." + "noticeLater": "You can change this at any time in Settings → Usage statistics.", + "sendNow": "Send now", + "sentToast": "Report sent", + "sendFailedToast": "Could not send the report: {error}" } } diff --git a/src/app/api/insights/send/route.ts b/src/app/api/insights/send/route.ts new file mode 100644 index 00000000..64b98388 --- /dev/null +++ b/src/app/api/insights/send/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' +import { getInsightsStatus, sendInsights } from '@/features/insights/insights.service' +import { requireRole } from '@/lib/api/auth-guard' + +/** "Send now" in Settings: the same report the daily timer sends, on demand. */ +export async function POST() { + const guard = await requireRole('admin') + if (!guard.authorized) return guard.response + + try { + const result = await sendInsights() + + if (!result.sent) { + const status = result.reason === 'failed' ? 502 : 409 + return NextResponse.json( + { success: false, error: result.error ?? result.reason, reason: result.reason }, + { status } + ) + } + + const insightsStatus = await getInsightsStatus() + return NextResponse.json({ success: true, status: insightsStatus }) + } catch (error) { + console.error('Error sending insights report:', error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/src/components/settings-modal/components/insights-settings.tsx b/src/components/settings-modal/components/insights-settings.tsx index c7722e3f..3c301d1b 100644 --- a/src/components/settings-modal/components/insights-settings.tsx +++ b/src/components/settings-modal/components/insights-settings.tsx @@ -1,6 +1,6 @@ 'use client' -import { Loader2Icon } from 'lucide-react' +import { Loader2Icon, SendIcon } from 'lucide-react' import { toast } from 'sonner' import { useTranslations } from 'next-intl' import Link from 'next/link' @@ -8,7 +8,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' -import { useInsights, useUpdateInsights } from '@/features/insights/hooks/use-insights' +import { useInsights, useSendInsightsNow, useUpdateInsights } from '@/features/insights/hooks/use-insights' import { formatDate, FULL_DATE_FORMAT } from '@/lib/date' const INSIGHTS_DASHBOARD_URL = 'https://tagr.xavirincon.com/analytics/' @@ -17,6 +17,7 @@ export function InsightsSettings() { const t = useTranslations('insights') const { data: status, isLoading } = useInsights() const updateInsights = useUpdateInsights() + const sendNow = useSendInsightsNow() const handleToggle = (enabled: boolean) => { updateInsights.mutate(enabled, { @@ -25,6 +26,13 @@ export function InsightsSettings() { }) } + const handleSendNow = () => { + sendNow.mutate(undefined, { + onSuccess: () => toast.success(t('sentToast')), + onError: error => toast.error(t('sendFailedToast', { error: error.message })) + }) + } + if (isLoading || !status) { return (

@@ -61,11 +69,21 @@ export function InsightsSettings() {
)} -

- {status.lastSentAt - ? t('lastSent', { date: formatDate(status.lastSentAt, FULL_DATE_FORMAT) ?? '' }) - : t('neverSent')} -

+
+

+ {status.lastSentAt + ? t('lastSent', { date: formatDate(status.lastSentAt, FULL_DATE_FORMAT) ?? '' }) + : t('neverSent')} +

+ +

{t.rich('dashboard', { diff --git a/src/features/insights/consts.ts b/src/features/insights/consts.ts index dbb9b265..af4ac571 100644 --- a/src/features/insights/consts.ts +++ b/src/features/insights/consts.ts @@ -1,5 +1,5 @@ /** Every published version reports here, so it lives on a domain we control rather than workers.dev. */ -export const INSIGHTS_ENDPOINT = 'https://analytics.tagr.xavirincon.com/collect' +export const INSIGHTS_ENDPOINT = 'https://tagr-analytics.xavirincon.com/collect' /** Same cadence as Navidrome: skip short-lived restarts, then once a day. */ export const INSIGHTS_INITIAL_DELAY_MS = 30 * 60 * 1000 diff --git a/src/features/insights/domain.ts b/src/features/insights/domain.ts index 24f2b768..d1531e1d 100644 --- a/src/features/insights/domain.ts +++ b/src/features/insights/domain.ts @@ -57,3 +57,7 @@ export interface InsightsNoticeState { /** The admin still has to be told that reports are sent */ pending: boolean } + +export type InsightsSendResult = + | { sent: true } + | { sent: false; reason: 'disabled' | 'noticePending' | 'failed'; error?: string } diff --git a/src/features/insights/hooks/use-insights.ts b/src/features/insights/hooks/use-insights.ts index 475398d9..c90159fb 100644 --- a/src/features/insights/hooks/use-insights.ts +++ b/src/features/insights/hooks/use-insights.ts @@ -43,3 +43,17 @@ export function useUpdateInsights() { } }) } + +export function useSendInsightsNow() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + const { data } = await api.post<{ success: true; status: InsightsStatus }>('/insights/send') + return data.status + }, + onSuccess: status => { + queryClient.setQueryData(INSIGHTS_QUERY_KEY, status) + } + }) +} diff --git a/src/features/insights/insights.service.test.ts b/src/features/insights/insights.service.test.ts index 9be56e4a..1e6e328b 100644 --- a/src/features/insights/insights.service.test.ts +++ b/src/features/insights/insights.service.test.ts @@ -130,7 +130,7 @@ describe('insights.service', () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 }) vi.stubGlobal('fetch', fetchMock) - await sendInsights() + await expect(sendInsights()).resolves.toEqual({ sent: true }) expect(fetchMock).toHaveBeenCalledWith('http://localhost:8787/collect', expect.objectContaining({ method: 'POST' })) expect(mockSetAppProperty).toHaveBeenCalledWith('insightsLastSentAt', expect.any(String)) @@ -146,11 +146,19 @@ describe('insights.service', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('reports a rejection by the server', async () => { + mockGetAppProperty.mockResolvedValue('id') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400 })) + + await expect(sendInsights()).resolves.toEqual({ sent: false, reason: 'failed', error: 'HTTP 400' }) + expect(mockSetAppProperty).not.toHaveBeenCalledWith('insightsLastSentAt', expect.anything()) + }) + it('swallows network errors', async () => { mockGetAppProperty.mockResolvedValue('id') vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) - await expect(sendInsights()).resolves.toBeUndefined() + await expect(sendInsights()).resolves.toEqual({ sent: false, reason: 'failed', error: 'offline' }) expect(mockSetAppProperty).not.toHaveBeenCalledWith('insightsLastSentAt', expect.anything()) }) }) diff --git a/src/features/insights/insights.service.ts b/src/features/insights/insights.service.ts index f1cdcc4d..680924f2 100644 --- a/src/features/insights/insights.service.ts +++ b/src/features/insights/insights.service.ts @@ -10,7 +10,7 @@ import { INSIGHTS_NOTICE_SEEN_KEY, INSIGHTS_REQUEST_TIMEOUT_MS } from './consts' -import type { InsightsData, InsightsNoticeState, InsightsStatus } from './domain' +import type { InsightsData, InsightsNoticeState, InsightsSendResult, InsightsStatus } from './domain' import { countFeatureUsage, countRecentActivity, @@ -122,9 +122,9 @@ export async function collectInsights(): Promise { * Nothing leaves the server until an admin has seen the in-app notice, so an upgrade never * starts reporting behind anyone's back. */ -export async function sendInsights(): Promise { - if (!(await isInsightsEnabled())) return - if (!(await isInsightsNoticeAcknowledged())) return +export async function sendInsights(): Promise { + if (!(await isInsightsEnabled())) return { sent: false, reason: 'disabled' } + if (!(await isInsightsNoticeAcknowledged())) return { sent: false, reason: 'noticePending' } const endpoint = getInsightsEndpoint() @@ -139,12 +139,17 @@ export async function sendInsights(): Promise { if (!response.ok) { console.warn(`Insights report rejected by ${endpoint}: ${response.status}`) - return + return { sent: false, reason: 'failed', error: `HTTP ${response.status}` } } await setAppProperty(INSIGHTS_LAST_SENT_KEY, new Date().toISOString()) + return { sent: true } } catch (error) { - console.warn(`Could not send insights report to ${endpoint}:`, error instanceof Error ? error.message : error) + // fetch only says "fetch failed"; the reason (DNS, TLS, refused…) is in `cause` + const cause = error instanceof Error ? (error.cause as { code?: string; message?: string } | undefined) : undefined + const message = cause?.code ?? cause?.message ?? (error instanceof Error ? error.message : String(error)) + console.warn(`Could not send insights report to ${endpoint}:`, message) + return { sent: false, reason: 'failed', error: message } } } From 6e5b9e90c4adfb83a8e59787dda8f1b40f4bd900 Mon Sep 17 00:00:00 2001 From: Xavier Rincon Date: Thu, 8 Oct 2026 18:13:29 +0200 Subject: [PATCH 4/5] change naming --- README.md | 12 +++++----- landing/public/llms.txt | 2 +- .../{Analytics.astro => Insights.astro} | 10 ++++---- landing/src/i18n/en.ts | 24 +++++++++---------- landing/src/i18n/es.ts | 24 +++++++++---------- landing/src/i18n/index.ts | 4 ++-- ...ics-dashboard.ts => insights-dashboard.ts} | 14 +++++++---- landing/src/pages/analytics.astro | 18 -------------- landing/src/pages/es/analytics.astro | 18 -------------- landing/src/pages/es/insights.astro | 18 ++++++++++++++ landing/src/pages/insights.astro | 18 ++++++++++++++ messages/en.json | 20 ++++++++-------- src/components/insights-notice-dialog.tsx | 2 +- .../components/insights-settings.tsx | 2 +- src/features/insights/consts.ts | 2 +- src/features/insights/insights.service.ts | 10 ++++---- 16 files changed, 102 insertions(+), 96 deletions(-) rename landing/src/components/{Analytics.astro => Insights.astro} (94%) rename landing/src/lib/{analytics-dashboard.ts => insights-dashboard.ts} (97%) delete mode 100644 landing/src/pages/analytics.astro delete mode 100644 landing/src/pages/es/analytics.astro create mode 100644 landing/src/pages/es/insights.astro create mode 100644 landing/src/pages/insights.astro diff --git a/README.md b/README.md index ef7d19ef..f087161f 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ pnpm dev # Development mode | `MUSIC_FOLDERS` | No | Comma-separated list of paths to music directories. Defaults to `/music` if not set. | | `PUID` | No | User ID for the container process. Defaults to `1000`. (Docker only) | | `PGID` | No | Group ID for the container process. Defaults to `1000`. (Docker only) | -| `TAGR_INSIGHTS` | No | Set to `false` to stop sending [anonymous usage statistics](#anonymous-usage-statistics). `DO_NOT_TRACK=1` does the same. | +| `TAGR_INSIGHTS` | No | Set to `false` to stop sending [Insights](#insights). `DO_NOT_TRACK=1` does the same. | --- @@ -279,11 +279,11 @@ and play counts inside Tagr are recorded regardless of whether scrobbling is con --- -## Anonymous usage statistics +## Insights -Once a day, Tagr sends a small anonymous report to `https://tagr-analytics.xavirincon.com/collect` so we can tell +Insights are Tagr's anonymous usage statistics. Once a day, Tagr sends a small anonymous report to `https://tagr-insights.xavirincon.com/collect` so we can tell how many instances exist and which features are worth working on. The aggregated results are public at -. +. The report contains only counts and flags: @@ -298,11 +298,11 @@ It never includes file names, paths, tags, user names, tokens or your IP address **Nothing is sent until an admin has seen the notice.** After installing or upgrading, admins get a popup explaining what is sent, with **Keep enabled** and **Disable** buttons. Until one of them is clicked (or -the setting is changed in **Settings → Usage statistics**, which also shows the exact JSON), no report leaves the +the setting is changed in **Settings → Insights**, which also shows the exact JSON), no report leaves the server. After that, a report goes out 30 minutes after start-up and then every 24 hours. The container log also says at start-up whether statistics are on. -To opt out, untick it in **Settings → Usage statistics**, or set `TAGR_INSIGHTS=false` (or `DO_NOT_TRACK=1`) to +To opt out, untick it in **Settings → Insights**, or set `TAGR_INSIGHTS=false` (or `DO_NOT_TRACK=1`) to turn it off for good. The collector is open source: [suitux/tagr-insights](https://github.com/suitux/tagr-insights). --- diff --git a/landing/public/llms.txt b/landing/public/llms.txt index 39ede8bd..a24255e6 100644 --- a/landing/public/llms.txt +++ b/landing/public/llms.txt @@ -37,7 +37,7 @@ server itself and gives you a browser UI, on desktop and on a phone. - Mobile first responsive UI with swipe gestures, installable as a PWA. - Custom playlists, lossless detection, URL based state (bookmark or share any view). - Multi user authentication with user management built in. -- No telemetry. The only outbound call is to the MusicBrainz API, and only on request. +- Private by design: music, tags and file names never leave the server. Outbound calls: MusicBrainz lookups on request, ListenBrainz scrobbling if configured, and Insights, an anonymous daily report of counts that an admin must accept first and can turn off (public results: https://tagr.xavirincon.com/insights/). ## Supported audio formats diff --git a/landing/src/components/Analytics.astro b/landing/src/components/Insights.astro similarity index 94% rename from landing/src/components/Analytics.astro rename to landing/src/components/Insights.astro index af5de142..8a49f460 100644 --- a/landing/src/components/Analytics.astro +++ b/landing/src/components/Insights.astro @@ -7,7 +7,7 @@ interface Props { const { locale } = Astro.props; const d = t(locale); -const a = d.analytics; +const a = d.insights; // Overridable for local work against `wrangler dev` (PUBLIC_INSIGHTS_API=http://localhost:8787). const api = import.meta.env.PUBLIC_INSIGHTS_API ?? LINKS.insightsApi; @@ -18,7 +18,7 @@ const charts = ['instances', 'versions', 'platforms', 'librarySizes', 'features' ---

diff --git a/landing/src/i18n/en.ts b/landing/src/i18n/en.ts index c001d884..39482cc4 100644 --- a/landing/src/i18n/en.ts +++ b/landing/src/i18n/en.ts @@ -12,11 +12,11 @@ export const en = { 'How to install and configure Tagr, the self hosted music metadata editor. Docker Compose, environment variables, music folder mounting, and troubleshooting.', ogTitle: 'Install Tagr: Docker setup and configuration', }, - analytics: { - title: 'Tagr usage analytics: anonymous statistics from every instance', + insights: { + title: 'Tagr Insights: anonymous usage statistics from every instance', description: 'Public, aggregated and anonymous usage statistics sent by Tagr instances: active installations, versions, platforms, library sizes and feature adoption over time.', - ogTitle: 'Tagr usage analytics', + ogTitle: 'Tagr Insights', }, notFound: { title: 'Page not found: Tagr', @@ -67,7 +67,7 @@ export const en = { trust: [ { icon: 'scale', label: 'AGPL-3.0' }, { icon: 'cpu', label: 'amd64 and arm64' }, - { icon: 'shield', label: 'No telemetry' }, + { icon: 'shield', label: 'Private by design' }, { icon: 'database', label: 'SQLite, no external database' }, { icon: 'smartphone', label: 'Works on mobile' }, ], @@ -379,7 +379,7 @@ export const en = { }, { q: 'Does Tagr send any data to the cloud?', - a: 'No telemetry, no analytics, no phoning home. The one outbound call Tagr can make is to the MusicBrainz API, and only when you explicitly ask it to look a song up. Skip that feature and the container never talks to the internet.', + a: 'Your music, tags and file names never leave your server. Tagr only goes online for three things: MusicBrainz lookups when you ask for one, ListenBrainz scrobbling if you connect an account, and Insights, one anonymous daily report of counts (version, library size, which features are used). Insights waits until an admin agrees and can be turned off at any time; the results are public on the Insights page.', }, { q: 'Can I use Tagr from my phone?', @@ -435,7 +435,7 @@ export const en = { links: [ { label: 'GitHub', href: 'https://github.com/suitux/Tagr', external: true }, { label: 'Releases', href: 'https://github.com/suitux/Tagr/releases', external: true }, - { label: 'Usage statistics', href: '/analytics/' }, + { label: 'Insights', href: '/insights/' }, { label: 'Issues', href: 'https://github.com/suitux/Tagr/issues', external: true }, { label: 'License', href: 'https://github.com/suitux/Tagr/blob/main/LICENSE', external: true }, ], @@ -452,17 +452,17 @@ export const en = { rights: 'Tagr, AGPL-3.0', }, - analytics: { - h1: 'Usage analytics', + insights: { + h1: 'Insights', intro: - 'Every Tagr instance sends one anonymous report a day: version, platform, library size and which features are in use. Never file names, paths, tags or user names. These are the aggregated results, updated daily.', - optOut: 'To opt out, turn it off in Settings or set TAGR_INSIGHTS=false.', + 'Insights are the anonymous usage statistics of Tagr. Every instance whose admin agreed sends one report a day: version, platform, library size and which features are in use. Never file names, paths, tags or user names. These are the aggregated results, updated live.', + optOut: 'To opt out, turn it off in Settings → Insights or set TAGR_INSIGHTS=false.', whatIsSent: 'See exactly what is sent', rangeLabel: 'Time range', ranges: { '30d': '30 days', '90d': '90 days', '1y': '1 year', all: 'All time' }, loading: 'Loading statistics…', error: 'Statistics could not be loaded. Try again later.', - empty: 'No data yet. Statistics appear here a day after the first reports arrive.', + empty: 'No data yet. Statistics appear here as soon as the first reports arrive.', updated: 'Updated {date}', vsStart: '{delta} since {date}', showTable: 'Show data as a table', @@ -476,7 +476,7 @@ export const en = { docker: 'Running in Docker', }, charts: { - instances: { title: 'Active installations', caption: 'Instances that reported on each day.' }, + instances: { title: 'Active installations', caption: 'Instances that reported on each day. The last point is live: every instance seen in the past 24 hours.' }, versions: { title: 'Tagr versions', caption: 'Installations per minor version. Shows how fast new releases are adopted.' }, platforms: { title: 'Operating system and architecture', caption: 'Share of installations.' }, librarySizes: { title: 'Library size', caption: 'Share of installations by number of songs.' }, diff --git a/landing/src/i18n/es.ts b/landing/src/i18n/es.ts index 8b883004..1da34e6b 100644 --- a/landing/src/i18n/es.ts +++ b/landing/src/i18n/es.ts @@ -14,11 +14,11 @@ export const es: Dict = { 'Cómo instalar y configurar Tagr, el editor de metadatos de música self hosted. Docker Compose, variables de entorno, montaje de carpetas de música y resolución de problemas.', ogTitle: 'Instalar Tagr: Docker y configuración', }, - analytics: { - title: 'Estadísticas de uso de Tagr: datos anónimos de cada instalación', + insights: { + title: 'Tagr Insights: estadísticas de uso anónimas de cada instalación', description: 'Estadísticas de uso públicas, agregadas y anónimas que envían las instalaciones de Tagr: instalaciones activas, versiones, plataformas, tamaño de biblioteca y adopción de funciones a lo largo del tiempo.', - ogTitle: 'Estadísticas de uso de Tagr', + ogTitle: 'Tagr Insights', }, notFound: { title: 'Página no encontrada: Tagr', @@ -69,7 +69,7 @@ export const es: Dict = { trust: [ { icon: 'scale', label: 'AGPL-3.0' }, { icon: 'cpu', label: 'amd64 y arm64' }, - { icon: 'shield', label: 'Sin telemetría' }, + { icon: 'shield', label: 'Privado por diseño' }, { icon: 'database', label: 'SQLite, sin base de datos externa' }, { icon: 'smartphone', label: 'Funciona en el móvil' }, ], @@ -381,7 +381,7 @@ export const es: Dict = { }, { q: '¿Tagr envía datos a la nube?', - a: 'Ni telemetría, ni analítica, ni llamadas a casa. La única salida a internet que puede hacer Tagr es a la API de MusicBrainz, y solo cuando le pides explícitamente que busque una canción. Si no usas esa función, el contenedor no habla con internet.', + a: 'Tu música, tus etiquetas y los nombres de tus archivos nunca salen de tu servidor. Tagr solo se conecta a internet para tres cosas: buscar en MusicBrainz cuando se lo pides, hacer scrobbling a ListenBrainz si conectas una cuenta, e Insights, un informe anónimo diario con recuentos (versión, tamaño de la biblioteca, qué funciones se usan). Insights espera a que un admin lo acepte y se puede desactivar en cualquier momento; los resultados son públicos en la página de Insights.', }, { q: '¿Puedo usar Tagr desde el móvil?', @@ -437,7 +437,7 @@ export const es: Dict = { links: [ { label: 'GitHub', href: 'https://github.com/suitux/Tagr', external: true }, { label: 'Versiones', href: 'https://github.com/suitux/Tagr/releases', external: true }, - { label: 'Estadísticas de uso', href: '/es/analytics/' }, + { label: 'Insights', href: '/es/insights/' }, { label: 'Incidencias', href: 'https://github.com/suitux/Tagr/issues', external: true }, { label: 'Licencia', href: 'https://github.com/suitux/Tagr/blob/main/LICENSE', external: true }, ], @@ -454,17 +454,17 @@ export const es: Dict = { rights: 'Tagr, AGPL-3.0', }, - analytics: { - h1: 'Estadísticas de uso', + insights: { + h1: 'Insights', intro: - 'Cada instalación de Tagr envía un informe anónimo al día: versión, plataforma, tamaño de la biblioteca y qué funciones usa. Nunca nombres de archivo, rutas, etiquetas ni nombres de usuario. Estos son los resultados agregados, actualizados a diario.', - optOut: 'Para no enviarlo, desactívalo en Ajustes o define TAGR_INSIGHTS=false.', + 'Insights son las estadísticas de uso anónimas de Tagr. Cada instalación cuyo admin lo ha aceptado envía un informe al día: versión, plataforma, tamaño de la biblioteca y qué funciones usa. Nunca nombres de archivo, rutas, etiquetas ni nombres de usuario. Estos son los resultados agregados, actualizados en directo.', + optOut: 'Para no enviarlo, desactívalo en Ajustes → Insights o define TAGR_INSIGHTS=false.', whatIsSent: 'Ver exactamente qué se envía', rangeLabel: 'Periodo', ranges: { '30d': '30 días', '90d': '90 días', '1y': '1 año', all: 'Todo' }, loading: 'Cargando estadísticas…', error: 'No se han podido cargar las estadísticas. Inténtalo más tarde.', - empty: 'Aún no hay datos. Las estadísticas aparecen un día después de que lleguen los primeros informes.', + empty: 'Aún no hay datos. Las estadísticas aparecen en cuanto llegan los primeros informes.', updated: 'Actualizado el {date}', vsStart: '{delta} desde el {date}', showTable: 'Ver los datos en una tabla', @@ -478,7 +478,7 @@ export const es: Dict = { docker: 'Funcionando en Docker', }, charts: { - instances: { title: 'Instalaciones activas', caption: 'Instalaciones que enviaron su informe cada día.' }, + instances: { title: 'Instalaciones activas', caption: 'Instalaciones que enviaron su informe cada día. El último punto es en directo: todas las vistas en las últimas 24 horas.' }, versions: { title: 'Versiones de Tagr', caption: 'Instalaciones por versión. Muestra lo rápido que se adoptan las nuevas releases.' }, platforms: { title: 'Sistema operativo y arquitectura', caption: 'Porcentaje de instalaciones.' }, librarySizes: { title: 'Tamaño de la biblioteca', caption: 'Porcentaje de instalaciones según su número de canciones.' }, diff --git a/landing/src/i18n/index.ts b/landing/src/i18n/index.ts index b0057fb0..ba99ac63 100644 --- a/landing/src/i18n/index.ts +++ b/landing/src/i18n/index.ts @@ -40,6 +40,6 @@ export const LINKS = { image: 'ghcr.io/suitux/tagr:latest', version: '1.8.6', license: 'https://www.gnu.org/licenses/agpl-3.0.html', - insightsApi: 'https://tagr-analytics.xavirincon.com', - insightsDocs: 'https://github.com/suitux/Tagr#anonymous-usage-statistics', + insightsApi: 'https://tagr-insights.xavirincon.com', + insightsDocs: 'https://github.com/suitux/Tagr#insights', } as const; diff --git a/landing/src/lib/analytics-dashboard.ts b/landing/src/lib/insights-dashboard.ts similarity index 97% rename from landing/src/lib/analytics-dashboard.ts rename to landing/src/lib/insights-dashboard.ts index 14c979dd..dbcc2fbe 100644 --- a/landing/src/lib/analytics-dashboard.ts +++ b/landing/src/lib/insights-dashboard.ts @@ -15,7 +15,7 @@ import type { en } from '../i18n/en'; Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip, Legend); -type Labels = (typeof en)['analytics']; +type Labels = (typeof en)['insights']; type Range = '30d' | '90d' | '1y' | 'all'; type ChartId = 'instances' | 'versions' | 'platforms' | 'librarySizes' | 'features' | 'activity' | 'formats'; @@ -34,6 +34,7 @@ interface DailySummary { interface SummaryResponse { range: Range; granularity: 'day' | 'week'; + /** When the live point was computed (ISO timestamp) */ updatedAt: string | null; totalInstances: number; history: { day: string; summary: DailySummary }[]; @@ -189,7 +190,7 @@ function buildSpecs(history: SummaryResponse['history'], labels: Labels): Record }; } -export function initAnalyticsDashboard(root: HTMLElement): void { +export function initInsightsDashboard(root: HTMLElement): void { const api = root.dataset.api!.replace(/\/$/, ''); const locale = root.dataset.locale ?? 'en'; const labels = JSON.parse(root.dataset.labels!) as Labels; @@ -421,7 +422,12 @@ export function initAnalyticsDashboard(root: HTMLElement): void { return; } - status.textContent = data.updatedAt ? labels.updated.replace('{date}', formatDate(data.updatedAt, true)) : ''; + status.textContent = data.updatedAt + ? labels.updated.replace( + '{date}', + new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(data.updatedAt)), + ) + : ''; lastSpecs = buildSpecs(data.history, labels); lastDates = data.history.map((point) => point.day); @@ -433,7 +439,7 @@ export function initAnalyticsDashboard(root: HTMLElement): void { } for (const details of tables) renderTable(details); } catch (error) { - console.error('Could not load Tagr usage statistics:', error); + console.error('Could not load Tagr Insights:', error); status.textContent = labels.error; } finally { content.style.opacity = ''; diff --git a/landing/src/pages/analytics.astro b/landing/src/pages/analytics.astro deleted file mode 100644 index b8ea5672..00000000 --- a/landing/src/pages/analytics.astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -import BaseLayout from '../layouts/BaseLayout.astro'; -import Analytics from '../components/Analytics.astro'; -import { t } from '../i18n'; - -const locale = 'en' as const; -const d = t(locale); ---- - - - - diff --git a/landing/src/pages/es/analytics.astro b/landing/src/pages/es/analytics.astro deleted file mode 100644 index 52299669..00000000 --- a/landing/src/pages/es/analytics.astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -import BaseLayout from '../../layouts/BaseLayout.astro'; -import Analytics from '../../components/Analytics.astro'; -import { t } from '../../i18n'; - -const locale = 'es' as const; -const d = t(locale); ---- - - - - diff --git a/landing/src/pages/es/insights.astro b/landing/src/pages/es/insights.astro new file mode 100644 index 00000000..eda2a7dd --- /dev/null +++ b/landing/src/pages/es/insights.astro @@ -0,0 +1,18 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import Insights from '../../components/Insights.astro'; +import { t } from '../../i18n'; + +const locale = 'es' as const; +const d = t(locale); +--- + + + + diff --git a/landing/src/pages/insights.astro b/landing/src/pages/insights.astro new file mode 100644 index 00000000..c12af036 --- /dev/null +++ b/landing/src/pages/insights.astro @@ -0,0 +1,18 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Insights from '../components/Insights.astro'; +import { t } from '../i18n'; + +const locale = 'en' as const; +const d = t(locale); +--- + + + + diff --git a/messages/en.json b/messages/en.json index 8de436f6..6fdbdb0c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -286,7 +286,7 @@ "timing": "This process may take a few minutes depending on the size of your library.", "scanButton": "Scan library", "scanning": "Scanning your library...", - "insightsNotice": "Tagr sends anonymous usage statistics once a day (no file names, paths or user names). You can turn this off in Settings." + "insightsNotice": "Tagr can send anonymous usage statistics (Insights) once a day: counts only, never file names, paths or user names. Nothing is sent until you agree, and you can turn it off in Settings." }, "version": { "updateAvailable": "Version {version} is available", @@ -512,7 +512,7 @@ "sections": { "users": "Users", "integrations": "Third party integrations", - "insights": "Usage statistics" + "insights": "Insights" } }, "scrobbling": { @@ -536,19 +536,19 @@ "empty": "Nothing played yet" }, "insights": { - "title": "Anonymous usage statistics", - "description": "Once a day Tagr sends a small anonymous report (version, library size, which features are used) so we know how many people use it and what to improve. It never includes file names, paths, tags or user names.", - "enable": "Send anonymous usage statistics", + "title": "Insights", + "description": "Insights are Tagr's anonymous usage statistics. Once a day Tagr sends a small report (version, library size, which features are used) so we know how many people use it and what to improve. It never includes file names, paths, tags or user names.", + "enable": "Send Insights (anonymous usage statistics)", "forcedByEnv": "Disabled by the TAGR_INSIGHTS or DO_NOT_TRACK environment variable.", "lastSent": "Last report sent on {date}.", "neverSent": "No report sent yet.", "dashboard": "The aggregated results are public: see the dashboard.", "preview": "Show exactly what is sent", "previewHelp": "This JSON is sent to {endpoint}:", - "enabledToast": "Usage statistics enabled", - "disabledToast": "Usage statistics disabled", - "noticeTitle": "Help improve Tagr with anonymous usage statistics", - "noticeBody": "Tagr can send a small anonymous report once a day so we know how many people use it and which features are worth working on. Nothing has been sent yet, and nothing will be unless you keep it enabled.", + "enabledToast": "Insights enabled", + "disabledToast": "Insights disabled", + "noticeTitle": "Help improve Tagr with Insights", + "noticeBody": "Insights are anonymous usage statistics: once a day Tagr can send a small report so we know how many people use it and which features are worth working on. Nothing has been sent yet, and nothing will be unless you keep it enabled.", "noticeDetails": "Show the exact report", "noticeKeep": "Keep enabled", "noticeDisable": "Disable", @@ -557,7 +557,7 @@ "noticeSent": "Tagr version, OS and architecture, number of songs and users, file formats, and how often features like tag editing, smart playlists or scrobbling are used.", "noticeNeverTitle": "What is never sent", "noticeNever": "File names, paths, tags, user names, tokens or your IP address.", - "noticeLater": "You can change this at any time in Settings → Usage statistics.", + "noticeLater": "You can change this at any time in Settings → Insights.", "sendNow": "Send now", "sentToast": "Report sent", "sendFailedToast": "Could not send the report: {error}" diff --git a/src/components/insights-notice-dialog.tsx b/src/components/insights-notice-dialog.tsx index d4c0c1f4..9594cd6b 100644 --- a/src/components/insights-notice-dialog.tsx +++ b/src/components/insights-notice-dialog.tsx @@ -13,7 +13,7 @@ import type { UserRole } from '@/features/users/domain' import { hasMinimumRole } from '@/features/users/lib/hasMinimumRole' import { cn } from '@/lib/utils' -const INSIGHTS_DASHBOARD_URL = 'https://tagr.xavirincon.com/analytics/' +const INSIGHTS_DASHBOARD_URL = 'https://tagr.xavirincon.com/insights/' /** * Asks an admin once, after installing or upgrading, whether to send usage statistics. diff --git a/src/components/settings-modal/components/insights-settings.tsx b/src/components/settings-modal/components/insights-settings.tsx index 3c301d1b..f091a2b1 100644 --- a/src/components/settings-modal/components/insights-settings.tsx +++ b/src/components/settings-modal/components/insights-settings.tsx @@ -11,7 +11,7 @@ import { Label } from '@/components/ui/label' import { useInsights, useSendInsightsNow, useUpdateInsights } from '@/features/insights/hooks/use-insights' import { formatDate, FULL_DATE_FORMAT } from '@/lib/date' -const INSIGHTS_DASHBOARD_URL = 'https://tagr.xavirincon.com/analytics/' +const INSIGHTS_DASHBOARD_URL = 'https://tagr.xavirincon.com/insights/' export function InsightsSettings() { const t = useTranslations('insights') diff --git a/src/features/insights/consts.ts b/src/features/insights/consts.ts index af4ac571..feaa06ae 100644 --- a/src/features/insights/consts.ts +++ b/src/features/insights/consts.ts @@ -1,5 +1,5 @@ /** Every published version reports here, so it lives on a domain we control rather than workers.dev. */ -export const INSIGHTS_ENDPOINT = 'https://tagr-analytics.xavirincon.com/collect' +export const INSIGHTS_ENDPOINT = 'https://tagr-insights.xavirincon.com/collect' /** Same cadence as Navidrome: skip short-lived restarts, then once a day. */ export const INSIGHTS_INITIAL_DELAY_MS = 30 * 60 * 1000 diff --git a/src/features/insights/insights.service.ts b/src/features/insights/insights.service.ts index 680924f2..6f51f57c 100644 --- a/src/features/insights/insights.service.ts +++ b/src/features/insights/insights.service.ts @@ -175,22 +175,22 @@ export async function getInsightsStatus(): Promise { export async function logInsightsStartupState(): Promise { try { if (getEnvInsightsOverride() === false) { - console.info('Anonymous usage statistics are disabled by TAGR_INSIGHTS / DO_NOT_TRACK.') + console.info('Insights (anonymous usage statistics) are disabled by TAGR_INSIGHTS / DO_NOT_TRACK.') return } if (!(await isInsightsEnabled())) { - console.info('Anonymous usage statistics are disabled in Settings.') + console.info('Insights (anonymous usage statistics) are disabled in Settings.') return } const base = - 'Tagr sends anonymous usage statistics once a day (counts only, see the README). ' + - 'Turn them off in Settings → Usage statistics or with TAGR_INSIGHTS=false.' + 'Insights: Tagr sends anonymous usage statistics once a day (counts only, see the README). ' + + 'Turn them off in Settings → Insights or with TAGR_INSIGHTS=false.' console.info( (await isInsightsNoticeAcknowledged()) ? base : `${base} Nothing is sent until an admin has seen the notice in the web UI.` ) } catch (error) { - console.warn('Could not read the usage statistics setting:', error instanceof Error ? error.message : error) + console.warn('Could not read the Insights setting:', error instanceof Error ? error.message : error) } } From 54cb46c94ee0712e32e7a0feb31831f61a0e709f Mon Sep 17 00:00:00 2001 From: Xavier Rincon Date: Thu, 8 Oct 2026 18:28:06 +0200 Subject: [PATCH 5/5] landing updated --- landing/src/i18n/en.ts | 12 ++++++++++++ landing/src/i18n/es.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/landing/src/i18n/en.ts b/landing/src/i18n/en.ts index 39482cc4..67a6066c 100644 --- a/landing/src/i18n/en.ts +++ b/landing/src/i18n/en.ts @@ -582,6 +582,18 @@ export const en = { desc: 'Runtime mode. Use production for a normal deployment.', example: 'production', }, + { + name: 'TAGR_INSIGHTS', + required: 'No', + desc: 'Set to false to turn off Insights, the anonymous daily usage report, for good. It overrides the Settings toggle, and the admin is never asked about it.', + example: 'false', + }, + { + name: 'DO_NOT_TRACK', + required: 'No', + desc: 'Set to 1 to turn off Insights, same as TAGR_INSIGHTS=false. Follows the Console Do Not Track convention.', + example: '1', + }, ], }, manual: { diff --git a/landing/src/i18n/es.ts b/landing/src/i18n/es.ts index 1da34e6b..c47a7bef 100644 --- a/landing/src/i18n/es.ts +++ b/landing/src/i18n/es.ts @@ -584,6 +584,18 @@ export const es: Dict = { desc: 'Modo de ejecución. Usa production en un despliegue normal.', example: 'production', }, + { + name: 'TAGR_INSIGHTS', + required: 'No', + desc: 'Ponlo a false para desactivar Insights, el informe anónimo diario de uso, de forma permanente. Tiene prioridad sobre el ajuste de la app, y no se le pregunta al admin.', + example: 'false', + }, + { + name: 'DO_NOT_TRACK', + required: 'No', + desc: 'Ponlo a 1 para desactivar Insights, igual que TAGR_INSIGHTS=false. Sigue la convención Console Do Not Track.', + example: '1', + }, ], }, manual: {