Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8f934da
chore(core): scaffold Musebase.Browser (in sln) + Musebase.Android stub
Jul 16, 2026
918db1f
feat(browser): PlaybackViewState WS broadcast + web display (Phase 1)
Jul 16, 2026
dfc0516
feat(android): MediaSession now-playing spike (Phase 2)
Jul 16, 2026
0723797
Merge pull request #2 from countnine/feat/browser/ws-server
countnine Jul 16, 2026
efb5a2a
Merge pull request #3 from countnine/feat/android/media-session
countnine Jul 16, 2026
36f2d3c
fix(android): alias MediaController to resolve CS0104 with implicit A…
Jul 16, 2026
36379ab
fix(android): embed assemblies into APK β€” sideloaded debug APK crashe…
Jul 16, 2026
2390020
feat(backend): telemetry ingest worker (Cloudflare Workers + D1)
Jul 16, 2026
df70c98
docs+core: telemetry foundation β€” ADR-0004, TELEMETRY.md, event contr…
Jul 16, 2026
87819d4
docs(contracts): clarify telemetry emission cadence (per-track transl…
Jul 17, 2026
3a1a7e5
feat(core): telemetry instrumentation β€” lyrics_search/not_found/wrong…
Jul 17, 2026
6177552
feat(windows): telemetry client, consent dialog, settings toggles
Jul 17, 2026
ece6545
docs(contracts): daily debounce = local calendar date, aligned across…
Jul 17, 2026
3ad3e94
Merge pull request #4 from countnine/core/telemetry-instrumentation
countnine Jul 17, 2026
35b99bb
Merge pull request #5 from countnine/feat/windows/telemetry-client
countnine Jul 17, 2026
68c55ff
feat: wire TelemetryClient into engine factory (activates core instru…
Jul 17, 2026
f211226
chore: bump to 0.10.0-beta.1 (first Musebase-branded public beta)
Jul 17, 2026
52d2398
feat(backend): token-protected /admin page β€” quality reports + fleet …
Jul 17, 2026
3abdbc8
feat(windows): 1h telemetry upload cadence + immediate upload on wron…
Jul 17, 2026
fc7a262
chore: bump to 0.10.0-beta.2 (admin page + faster telemetry cadence)
Jul 17, 2026
6d49d2d
chore: release windows-v0.10.0 β€” first Musebase release (version, doc…
Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ Releases/
# 둜컬 ν…ŒμŠ€νŠΈ λΉŒλ“œ μ‚°μΆœλ¬Ό
publish-test/
publish-singlefile/
.wrangler/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
| `src/Musebase.Android/` | android μ—μ΄μ „νŠΈ | (μ˜ˆμ •) .NET for Android |
| `src/Musebase.Browser/` | browser μ—μ΄μ „νŠΈ | (μ˜ˆμ •) ASP.NET WS 방솑 + μ›Ή λ””μŠ€ν”Œλ ˆμ΄ |
| `apple/` | apple μ—μ΄μ „νŠΈ | (μ˜ˆμ •) Swift β€” μ½”λ“œ λΉ„κ³΅μœ , `contracts/`둜만 μ •λ ¬ |
| `backend/telemetry/` | core μ—μ΄μ „νŠΈ | ν…”λ ˆλ©”νŠΈλ¦¬ μˆ˜μ§‘ Worker(Cloudflare, `wrangler deploy`둜 배포) |
| `tests/`, `docs/`, `scripts/`, `tools/` | 곡용 | μ†Œμœ  κ²½λ‘œμ— λŒ€μ‘ν•˜λŠ” λΆ€λΆ„λ§Œ μˆ˜μ • |

**골든룰: μ½”μ–΄(`Musebase.Core`/`Musebase.Engine`/`contracts/`)λŠ” core μ—μ΄μ „νŠΈλ§Œ μˆ˜μ •ν•œλ‹€.**
Expand Down
7 changes: 7 additions & 0 deletions Musebase.sln
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Musebase.Windows", "src\Mus
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Musebase.Engine", "src\Musebase.Engine\Musebase.Engine.csproj", "{F5BE32CA-78FF-4CE1-B073-5885C21C855C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Musebase.Browser", "src\Musebase.Browser\Musebase.Browser.csproj", "{9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -60,6 +62,10 @@ Global
{F5BE32CA-78FF-4CE1-B073-5885C21C855C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F5BE32CA-78FF-4CE1-B073-5885C21C855C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F5BE32CA-78FF-4CE1-B073-5885C21C855C}.Release|Any CPU.Build.0 = Release|Any CPU
{9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{767ED419-F293-4407-86F6-A8071FE9B5F1} = {6B10242B-5617-4D10-8710-14F7FD8C2A2B}
Expand All @@ -69,5 +75,6 @@ Global
{DACF699E-7AA1-4E83-B5B8-B437B0A25FFE} = {6B10242B-5617-4D10-8710-14F7FD8C2A2B}
{AB71719D-EE08-40E4-B14C-06E7479FF208} = {E2C96785-55D1-4839-885C-8AD7B97977B4}
{F5BE32CA-78FF-4CE1-B073-5885C21C855C} = {E2C96785-55D1-4839-885C-8AD7B97977B4}
{9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7} = {E2C96785-55D1-4839-885C-8AD7B97977B4}
EndGlobalSection
EndGlobal
7 changes: 6 additions & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# PROGRESS β€” Musebase for Windows (ꡬ LyricsX for Windows)

> **μƒνƒœ: v0.9.2 (2026-07-16) + Phase 0 μ§„ν–‰ 쀑** β€” μ œν’ˆ 개λͺ…(LyricsXβ†’Musebase, ν”„λ‘œμ νŠΈ/λ„€μž„μŠ€νŽ˜μ΄μŠ€/데이터 경둜 포함) + λ©€ν‹°ν”Œλž«νΌ κ±°λ²„λ„ŒμŠ€ μ…‹μ—… + LICENSE(MPL-2.0)
> **μƒνƒœ: windows-v0.10.0 (2026-07-17)** β€” 첫 Musebase 정식 릴리슀(packId 클린 브레이크). 개λͺ… + κ±°λ²„λ„ŒμŠ€ + MPL-2.0 + 옡트인 ν…”λ ˆλ©”νŠΈλ¦¬ + Browser/Android 슀파이크.
> 재개 방법: "μ΄μ–΄μ„œ"라고 μž…λ ₯ν•˜λ©΄ μ•„λž˜ λ°±λ‘œκ·ΈλΆ€ν„° μ§„ν–‰.

## v0.10.0 μΆ”κ°€λΆ„ (첫 Musebase 릴리슀)
- **옡트인 ν…”λ ˆλ©”νŠΈλ¦¬(ADR-0004)**: 읡λͺ… 랜덀 GUID, 2단계 λ™μ˜(β‘ κΈ°λ³Έ/β‘‘ν’ˆμ§ˆ β€” λ‹€μ΄μ–Όλ‘œκ·ΈΒ·μ„€μ • ν† κΈ€, κΈ°λ³Έ 꺼짐), Engine `ITelemetry` 계츑(lyrics_search/translation/wrong_lyrics/…), Windows `TelemetryClient`(JSONL νβ†’μ‹œμž‘ 30초+1μ‹œκ°„ μ£ΌκΈ°, 틀린가사 μ¦‰μ‹œ μ—…λ‘œλ“œ), λ°±μ—”λ“œ Cloudflare Workers+D1(`backend/telemetry/`, /stats 곡개·/admin 토큰 보호). 곡개 λ¬Έμ„œ `TELEMETRY.md`, 계약 `contracts/telemetry-events.md`.
- **Phase 1Β·2 슀파이크**: `src/Musebase.Browser`(PlaybackViewState WS 방솑+μ›Ή μΉ΄λΌμ˜€μΌ€ λ Œλ”λŸ¬, --demo) Β· `src/Musebase.Android`(MediaSession μž¬μƒκ°μ§€, μ‹€κΈ°κΈ° 검증, sln 미등둝).
- 릴리슀 νƒœκ·Έ μŠ€ν‚΄ μ „ν™˜: ν”Œλž«νΌ 접두 **`windows-vX.Y.Z`**(ADR-0003).

## Phase 0 (개λͺ… + κ±°λ²„λ„ŒμŠ€, 2026-07-16)
- **개λͺ… LyricsXβ†’Musebase**: ν”„λ‘œμ νŠΈ `Musebase.{Core,Engine,Windows}`(ꡬ Appβ†’Windows)Β·`Musebase.sln`Β·λ„€μž„μŠ€νŽ˜μ΄μŠ€Β·AssemblyName(`Musebase.exe`) 일괄. `%LOCALAPPDATA%\LyricsX`β†’`Musebase` μžλ™ 이전(`MigrateLegacyAppData`), DPAPI entropyλŠ” ν˜Έν™˜ μœ„ν•΄ `"LyricsX.DeepL.v1"` μœ μ§€, μ‹œμž‘ν”„λ‘œκ·Έλž¨ λ ˆμ§€μŠ€νŠΈλ¦¬ κ°’ `Musebase`(+ꡬ κ°’ 정리). Velopack packId `Musebase` = ꡬ μ„€μΉ˜λ³Έ μžλ™ μ—…λ°μ΄νŠΈ λ‹¨μ ˆ(클린 브레이크, RELEASING.md μ°Έκ³ ).
- **LICENSE(MPL-2.0) + 좜처 ν‘œκΈ°**: 원본 LyricsX/LyricsKit(ddddxxx, MPL-2.0) 기반 λͺ…μ‹œ. README λΌμ΄μ„ μŠ€ 절의 GPLv3 였기 μˆ˜μ •.
Expand Down
3 changes: 2 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ vpk upload github \
--repoUrl $REPO \
--publish \
--releaseName "Musebase $VERSION" \
--tag $VERSION \
--tag windows-v$VERSION \
--token <GITHUB_TOKEN>
# νƒœκ·ΈλŠ” ν”Œλž«νΌ 접두 μŠ€ν‚΄(ADR-0003): windows-vX.Y.Z. ν™ˆνŽ˜μ΄μ§€ 동기화가 접두λ₯Ό μ œκ±°ν•΄ ν‘œκΈ°ν•œλ‹€.
```

`vpk upload github`은 νƒœκ·Έ `$VERSION`으둜 릴리슀λ₯Ό λ§Œλ“€κ³  `RELEASES`, 델타 `.nupkg`,
Expand Down
52 changes: 52 additions & 0 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Musebase μ‚¬μš© 톡계(ν…”λ ˆλ©”νŠΈλ¦¬) μ•ˆλ‚΄ / Telemetry Notice

**ν•œκ΅­μ–΄** Β· [English below](#english)

MusebaseλŠ” μ„œλΉ„μŠ€ κ°œμ„ μ„ μœ„ν•΄ **μ‚¬μš©μžκ°€ λ™μ˜ν•œ κ²½μš°μ—λ§Œ** 읡λͺ… μ‚¬μš© 톡계λ₯Ό μˆ˜μ§‘ν•©λ‹ˆλ‹€.

- **기본값은 꺼짐(옡트인)** β€” 첫 μ‹€ν–‰ λ•Œ 묻고, μ„€μ • [일반] νƒ­μ—μ„œ μ–Έμ œλ“  λ°”κΏ€ 수 μžˆμŠ΅λ‹ˆλ‹€.
- **읡λͺ…** β€” 앱이 λ‘œμ»¬μ—μ„œ λ§Œλ“  λ¬΄μž‘μœ„ ID(GUID)만 μ‚¬μš©ν•©λ‹ˆλ‹€. 기기·계정 μ •λ³΄μ—μ„œ νŒŒμƒν•˜μ§€
μ•ŠμœΌλ©° μ„€μ •μ—μ„œ μž¬μ„€μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€. μ„œλ²„λŠ” IPλ₯Ό μ €μž₯ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
- **λ™μ˜λ₯Ό 끄면 μˆ˜μ§‘ 자체λ₯Ό ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€** ("λͺ¨μ•„두고 μ•ˆ λ³΄λ‚΄λŠ”" 방식이 μ•„λ‹˜).
- μˆ˜μ§‘ μ„œλ²„λ„ 직접 μš΄μ˜ν•©λ‹ˆλ‹€(Cloudflare Workers). **제3자 뢄석 μ„œλΉ„μŠ€μ— 보내지 μ•ŠμŠ΅λ‹ˆλ‹€.**
- 졜근 30일 μ§‘κ³„λŠ” λˆ„κ΅¬λ‚˜ λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€: https://musebase-telemetry.musebase.workers.dev/stats

## 무엇을 μˆ˜μ§‘ν•˜λ‚˜μš”?

### β‘  κΈ°λ³Έ 톡계 (곑 정보 μ—†μŒ)
| ν•­λͺ© | 예 | μ“°μž„ |
|---|---|---|
| μ•± 버전·OS 버전(λŒ€λΆ„λ₯˜)Β·UI μ–Έμ–΄Β·λ²ˆμ—­ λŒ€μƒ μ–Έμ–΄ | `0.10.0`, `Windows 10`, `ko` | 지원 μš°μ„ μˆœμœ„ |
| μž¬μƒ μ†ŒμŠ€ μ•± | `Spotify.exe` | μ–΄λ–€ μŒμ› μ„œλΉ„μŠ€λ₯Ό μš°μ„  지원할지 |
| 가사 검색 κ²°κ³Ό | 1μœ„ μ†ŒμŠ€, μ†ŒμŠ€λ³„ 히트/μ‹€νŒ¨/μ‘λ‹΅μ‹œκ°„, μΊμ‹œ 적쀑 | μ†ŒμŠ€ λž­ν‚ΉΒ·μ†λ„ κ°œμ„  |
| λ²ˆμ—­ | 선택 μ—”μ§„, μΊμ‹œ 적쀑λ₯  | 무료 μ—”μ§„ κΈ°λ³Έκ°’ 검증 |
| κΈ°λŠ₯ μ‚¬μš© 횟수 | λ―Έλ””μ–΄ 컨트둀/νŽΈμ§‘/내보내기 λ“± | UX μš°μ„ μˆœμœ„ |
| 였λ₯˜ | μ’…λ₯˜Β·λ°œμƒ μœ„μΉ˜(μŠ€νƒ μ΅œμƒμœ„) | ν¬λž˜μ‹œ μˆ˜μ • |

### β‘‘ ν’ˆμ§ˆ 리포트 (별도 λ™μ˜, 곑 제λͺ©Β·μ•„ν‹°μŠ€νŠΈ 포함)
| ν•­λͺ© | μ“°μž„ |
|---|---|
| "ν‹€λ¦° 가사"둜 ν‘œμ‹œν•œ 곑 (제λͺ©/μ•„ν‹°μŠ€νŠΈ/가사 μ†ŒμŠ€) | 였맀칭 μˆ˜μ • |
| 가사λ₯Ό λͺ» 찾은 곑 (제λͺ©/μ•„ν‹°μŠ€νŠΈ) | μ†ŒμŠ€ 컀버리지 ν™•λŒ€ |

### μ ˆλŒ€ μˆ˜μ§‘ν•˜μ§€ μ•ŠλŠ” 것
가사 λ³Έλ¬Έ, 전체 μž¬μƒ 이λ ₯, API ν‚€, 파일 경둜, 이메일·계정 λ“± κ°œμΈμ •λ³΄, IP μ €μž₯.

## 직접 ν™•μΈν•˜κΈ°
- 이벀트 계약: [`contracts/telemetry-events.md`](contracts/telemetry-events.md)
- μˆ˜μ§‘ μ„œλ²„ μ½”λ“œ: [`backend/telemetry/`](backend/telemetry/)
- μ•± μͺ½ κ΅¬ν˜„: `Musebase.Engine.ITelemetry` 및 각 ν”Œλž«νΌ ν—€λ“œ (μ „λΆ€ 이 리포에 곡개)
- 원본 이벀트 보쑴 90일, μ§‘κ³„λŠ” 영ꡬ.

---

## English

Musebase collects anonymous usage statistics **only if you opt in** (off by default; asked on
first run, changeable anytime in Settings). Two separate consents: **β‘  basic stats** (features,
performance, environment β€” no song data) and **β‘‘ quality reports** (title/artist of tracks you
mark as wrong lyrics or that fail lyrics search). The identifier is a locally generated random
GUID (resettable); the server stores no IP. When consent is off, nothing is collected at all.
We run our own collection endpoint (Cloudflare Workers) β€” no third-party analytics. Never
collected: lyrics text, full listening history, API keys, file paths, personal data.
30-day public aggregates: https://musebase-telemetry.musebase.workers.dev/stats
1 change: 1 addition & 0 deletions backend/telemetry/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.wrangler/
13 changes: 13 additions & 0 deletions backend/telemetry/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Musebase ν…”λ ˆλ©”νŠΈλ¦¬ 이벀트 μ €μž₯μ†Œ (D1/SQLite)
-- 원본 이벀트 보쑴 90일(μ •λ¦¬λŠ” μΆ”ν›„ 크둠), κ°œμΈμ •λ³΄ μ—†μŒ(읡λͺ… 랜덀 client_id).
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL, -- μ„œλ²„ μˆ˜μ‹  μ‹œκ°(ISO-8601, UTC)
client_id TEXT NOT NULL, -- 앱이 둜컬 μƒμ„±ν•œ 랜덀 GUID(μž¬μ„€μ • κ°€λŠ₯)
platform TEXT NOT NULL, -- windows | android | browser | macos | ios
app_version TEXT NOT NULL,
type TEXT NOT NULL, -- 이벀트 μ’…λ₯˜(app_session, lyrics_search, ...)
props TEXT NOT NULL DEFAULT '{}' -- μ΄λ²€νŠΈλ³„ 속성(JSON)
);
CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(type, received_at);
CREATE INDEX IF NOT EXISTS idx_events_time ON events(received_at);
146 changes: 146 additions & 0 deletions backend/telemetry/src/worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Musebase ν…”λ ˆλ©”νŠΈλ¦¬ μˆ˜μ§‘ Worker.
// - POST /ingest : 앱이 λ³΄λ‚΄λŠ” 읡λͺ… 이벀트 배치 μ €μž₯ (옡트인 μ‚¬μš©μžλ§Œ 전솑)
// - GET /stats : 졜근 30일 이벀트 μ’…λ₯˜λ³„ 건수 (투λͺ…μ„± μ°¨μ›μ—μ„œ 곡개)
// - GET /healthz
// κ°œμΈμ •λ³΄ μ—†μŒ: client_idλŠ” 앱이 λ§Œλ“  랜덀 GUID. IPλŠ” μ €μž₯ν•˜μ§€ μ•ŠλŠ”λ‹€.

const MAX_BODY_BYTES = 64 * 1024; // 배치 μƒν•œ 64KB
const MAX_EVENTS_PER_BATCH = 100;
const PLATFORMS = new Set(["windows", "android", "browser", "macos", "ios"]);
// 앱이 μ •μ˜ν•œ 이벀트만 수용(μ˜€λ‚¨μš©Β·μ“°λ ˆκΈ° 데이터 차단). contracts/telemetry-events.md와 동기화.
const EVENT_TYPES = new Set([
"app_session",
"playback_source",
"lyrics_search",
"lyrics_not_found",
"wrong_lyrics",
"translation",
"feature_use",
"error",
]);

function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}

async function handleIngest(request, env) {
if (request.headers.get("content-type")?.includes("application/json") !== true)
return json({ error: "content-type must be application/json" }, 415);

const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) return json({ error: "body too large" }, 413);

let body;
try { body = JSON.parse(raw); } catch { return json({ error: "invalid json" }, 400); }

const { clientId, platform, appVersion, events } = body ?? {};
if (typeof clientId !== "string" || clientId.length < 8 || clientId.length > 64)
return json({ error: "clientId" }, 400);
if (!PLATFORMS.has(platform)) return json({ error: "platform" }, 400);
if (typeof appVersion !== "string" || appVersion.length > 32)
return json({ error: "appVersion" }, 400);
if (!Array.isArray(events) || events.length === 0 || events.length > MAX_EVENTS_PER_BATCH)
return json({ error: "events" }, 400);

const now = new Date().toISOString();
const stmt = env.DB.prepare(
"INSERT INTO events (received_at, client_id, platform, app_version, type, props) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
);
const rows = [];
for (const e of events) {
if (!e || !EVENT_TYPES.has(e.type)) return json({ error: `unknown event type` }, 400);
const props = JSON.stringify(e.props ?? {});
if (props.length > 4096) return json({ error: "props too large" }, 400);
rows.push(stmt.bind(now, clientId, platform, appVersion, e.type, props));
}
await env.DB.batch(rows);
return json({ ok: true, stored: rows.length });
}

// ---- κ΄€λ¦¬μž 쑰회 νŽ˜μ΄μ§€ (토큰 보호) -------------------------------------------
// GET /admin?token=<ADMIN_TOKEN> β€” ν’ˆμ§ˆ 리포트(틀린가사/κ²€μƒ‰μ‹€νŒ¨, λΉˆλ„μˆœ) + ν˜„ν™© μš”μ•½ HTML.
// 토큰은 Worker Secret(ADMIN_TOKEN)으둜 관리: `wrangler secret put ADMIN_TOKEN`

function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, c =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}

async function handleAdmin(url, env) {
const token = url.searchParams.get("token") ?? "";
if (!env.ADMIN_TOKEN || token !== env.ADMIN_TOKEN)
return new Response("unauthorized", { status: 401 });

const [wrong, notFound, summary, versions] = await Promise.all([
env.DB.prepare(
`SELECT json_extract(props,'$.title') AS title, json_extract(props,'$.artist') AS artist,
json_extract(props,'$.source') AS source, COUNT(*) AS cnt,
COUNT(DISTINCT client_id) AS clients, MAX(received_at) AS last_seen
FROM events WHERE type='wrong_lyrics'
GROUP BY title, artist, source ORDER BY cnt DESC, last_seen DESC LIMIT 200`).all(),
env.DB.prepare(
`SELECT json_extract(props,'$.title') AS title, json_extract(props,'$.artist') AS artist,
COUNT(*) AS cnt, COUNT(DISTINCT client_id) AS clients, MAX(received_at) AS last_seen
FROM events WHERE type='lyrics_not_found'
GROUP BY title, artist ORDER BY cnt DESC, last_seen DESC LIMIT 200`).all(),
env.DB.prepare(
`SELECT type, COUNT(*) AS cnt, COUNT(DISTINCT client_id) AS clients, MAX(received_at) AS last_seen
FROM events GROUP BY type ORDER BY cnt DESC`).all(),
env.DB.prepare(
`SELECT platform, app_version, COUNT(DISTINCT client_id) AS clients, MAX(received_at) AS last_seen
FROM events GROUP BY platform, app_version ORDER BY last_seen DESC LIMIT 50`).all(),
]);

const rows = (rs, cols) => rs.results.length
? rs.results.map(r => `<tr>${cols.map(c => `<td>${esc(r[c])}</td>`).join("")}</tr>`).join("")
: `<tr><td colspan="${cols.length}" class="empty">아직 데이터 μ—†μŒ</td></tr>`;

const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex">
<title>Musebase ν…”λ ˆλ©”νŠΈλ¦¬ κ΄€λ¦¬μž</title><style>
body{font-family:system-ui,sans-serif;margin:2rem auto;max-width:64rem;padding:0 1rem;background:#111;color:#eee}
h1{font-size:1.3rem} h2{font-size:1.05rem;margin-top:2rem;border-bottom:1px solid #333;padding-bottom:.3rem}
table{border-collapse:collapse;width:100%;font-size:.85rem} th,td{border:1px solid #333;padding:.35rem .5rem;text-align:left}
th{background:#1c1c1c} tr:nth-child(even){background:#181818} .empty{color:#777;text-align:center}
.meta{color:#888;font-size:.8rem}
</style></head><body>
<h1>Musebase ν…”λ ˆλ©”νŠΈλ¦¬ κ΄€λ¦¬μž</h1>
<p class="meta">생성 μ‹œκ°(UTC): ${new Date().toISOString()} Β· 원본 보쑴 90일 Β· <a href="/stats" style="color:#7cc4ff">곡개 집계</a></p>
<h2>ν‹€λ¦° 가사 리포트 (λΉˆλ„μˆœ, μƒμœ„ 200)</h2>
<table><tr><th>곑λͺ…</th><th>μ•„ν‹°μŠ€νŠΈ</th><th>가사 μ†ŒμŠ€</th><th>건수</th><th>μ‚¬μš©μžμˆ˜</th><th>λ§ˆμ§€λ§‰</th></tr>
${rows(wrong, ["title", "artist", "source", "cnt", "clients", "last_seen"])}</table>
<h2>가사 검색 μ‹€νŒ¨ 곑 (λΉˆλ„μˆœ, μƒμœ„ 200)</h2>
<table><tr><th>곑λͺ…</th><th>μ•„ν‹°μŠ€νŠΈ</th><th>건수</th><th>μ‚¬μš©μžμˆ˜</th><th>λ§ˆμ§€λ§‰</th></tr>
${rows(notFound, ["title", "artist", "cnt", "clients", "last_seen"])}</table>
<h2>이벀트 전체 ν˜„ν™©</h2>
<table><tr><th>μ’…λ₯˜</th><th>건수</th><th>고유 μ‚¬μš©μž</th><th>λ§ˆμ§€λ§‰</th></tr>
${rows(summary, ["type", "cnt", "clients", "last_seen"])}</table>
<h2>ν”Œλž«νΌΒ·λ²„μ „ 뢄포</h2>
<table><tr><th>ν”Œλž«νΌ</th><th>μ•± 버전</th><th>고유 μ‚¬μš©μž</th><th>λ§ˆμ§€λ§‰</th></tr>
${rows(versions, ["platform", "app_version", "clients", "last_seen"])}</table>
</body></html>`;
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
}

async function handleStats(env) {
const since = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString();
const { results } = await env.DB.prepare(
`SELECT type, COUNT(*) AS count, COUNT(DISTINCT client_id) AS clients
FROM events WHERE received_at >= ?1 GROUP BY type ORDER BY count DESC`
).bind(since).all();
return json({ since, totals: results });
}

export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/healthz") return new Response("ok");
if (url.pathname === "/ingest" && request.method === "POST") return handleIngest(request, env);
if (url.pathname === "/stats" && request.method === "GET") return handleStats(env);
if (url.pathname === "/admin" && request.method === "GET") return handleAdmin(url, env);
return json({ error: "not found" }, 404);
},
};
11 changes: 11 additions & 0 deletions backend/telemetry/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Musebase ν…”λ ˆλ©”νŠΈλ¦¬ μˆ˜μ§‘ Worker (Cloudflare Workers + D1)
# 배포: backend/telemetry μ—μ„œ `wrangler deploy`
# μ„€κ³„Β·μˆ˜μ§‘ ν•­λͺ©: docs/adr/0004(μ˜ˆμ •) + TELEMETRY.md(μ˜ˆμ •)
name = "musebase-telemetry"
main = "src/worker.js"
compatibility_date = "2026-07-01"

[[d1_databases]]
binding = "DB"
database_name = "musebase-telemetry"
database_id = "7a4355b5-9813-421e-9b66-747651b60c87"
44 changes: 44 additions & 0 deletions contracts/telemetry-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ν…”λ ˆλ©”νŠΈλ¦¬ 이벀트 계약 (v1)

μ•± β†’ μˆ˜μ§‘ μ„œλ²„(`backend/telemetry`, `POST /ingest`)의 μ–Έμ–΄ 쀑립 계약. 단일 진싀은 이 λ¬Έμ„œ.
Worker의 ν™”μ΄νŠΈλ¦¬μŠ€νŠΈ(`EVENT_TYPES`)Β·μ•± 계츑·`TELEMETRY.md`λŠ” 이 λ¬Έμ„œμ™€ **같은 PRμ—μ„œ** λ™κΈ°ν™”ν•œλ‹€.
μ •μ±…(옡트인 2단계, 읡λͺ…)은 ADR-0004.

## 전솑 ν˜•μ‹

`POST https://musebase-telemetry.musebase.workers.dev/ingest` (application/json)

```json
{
"clientId": "f3a9…(둜컬 생성 랜덀 GUID, 8–64자)",
"platform": "windows | android | browser | macos | ios",
"appVersion": "0.10.0",
"events": [ { "type": "<μ•„λž˜ ν‘œ>", "props": { } } ]
}
```

- 배치 ≀ 100건, λ³Έλ¬Έ ≀ 64KB, `props` 직렬화 ≀ 4KB/건. μ΄ˆκ³ΌΒ·λ―Έμ •μ˜ type = 전체 400 κ±°λΆ€.
- μ‹œκ° ν•„λ“œλŠ” 보내지 μ•ŠλŠ”λ‹€ β€” μ„œλ²„ μˆ˜μ‹  μ‹œκ°(`received_at`)만 μ €μž₯(μ •λ°€ νƒ€μž„λΌμΈ λΆˆν•„μš”Β·μ΅λͺ…성↑).
- μ‹€νŒ¨ μ‹œ 앱은 둜컬 큐에 보관 ν›„ μž¬μ‹œλ„(μ΅œλŒ€ 보관 μƒν•œ 있음), μ•± λ™μž‘μ—λŠ” μ ˆλŒ€ 영ν–₯ μ—†μŒ.

## 이벀트 (β‘  = κΈ°λ³Έ 톡계 λ™μ˜, β‘‘ = ν’ˆμ§ˆ 리포트 λ™μ˜)

| type | λ™μ˜ | props | λΉ„κ³  |
|---|---|---|---|
| `app_session` | β‘  | `uiLang`, `targetLang`, `engine`(deepl/libretranslate/none), `sources`(ν™œμ„± μ†ŒμŠ€ id λ°°μ—΄), `sourceMode`(auto/νŠΉμ •μ•±), `osVersion`(λŒ€λΆ„λ₯˜, 예 "Windows 10") | ν•˜λ£¨ 1회(일일 ping 겸용) |
| `playback_source` | β‘  | `appId`(SMTC/MediaSession μ•± μ‹λ³„μž) | ν΄λΌμ΄μ–ΈνŠΈκ°€ ν•˜λ£¨ 쀑 앱별 1회둜 λ””λ°”μš΄μŠ€ |

"ν•˜λ£¨ 1회"의 κΈ°μ€€: **둜컬 λ‚ μ§œ(yyyy-MM-dd, μžμ • 리셋)** β€” 24μ‹œκ°„ 둀링 μ•„λ‹˜. λͺ¨λ“  ν”Œλž«νΌ ν—€λ“œ 동일.
| `lyrics_search` | β‘  | `winner`(채택 μ†ŒμŠ€ id λ˜λŠ” "none"), `perSource`({id: {hit: bool, latencyMs: int}}), `cached`(bool), `cleanedQueryUsed`(bool) | 곑 정보 μ—†μŒ |
| `lyrics_not_found` | β‘‘ | `title`, `artist` | 검색 μ‹€νŒ¨ 곑 |
| `wrong_lyrics` | β‘‘ | `title`, `artist`, `source`(μ±„νƒλλ˜ μ†ŒμŠ€ id) | "ν‹€λ¦° 가사" ν‘œμ‹œ μ‹œ |
| `translation` | β‘  | `engine`, `cacheHitPct`(0–100 μ •μˆ˜), `linesBucket`("1-10"/"11-50"/"51+") | 곑당 1회(λ²ˆμ—­ νŒŒμ΄ν”„λΌμΈ μ™„λ£Œ μ‹œ) |
| `feature_use` | β‘  | `feature`(mediaControls/edit/export/offset/search/karaoke…), `count`(int) | ν΄λΌμ΄μ–ΈνŠΈκ°€ μ„Έμ…˜ λ‹¨μœ„λ‘œ 집계해 전솑 |
| `error` | β‘  | `kind`(μ˜ˆμ™Έ νƒ€μž…λͺ…), `frame`(μ΅œμƒμœ„ μŠ€νƒν”„λ ˆμž„ 1개), `fatal`(bool) | λ©”μ‹œμ§€ 본문·경둜 κΈˆμ§€ |

## λ³€κ²½ κ·œμΉ™

- **props ν•„λ“œ μΆ”κ°€ = ν•˜μœ„ ν˜Έν™˜**(μ„œλ²„λŠ” JSON κ·ΈλŒ€λ‘œ μ €μž₯) β€” v1 μœ μ§€. type μΆ”κ°€λŠ” Worker
ν™”μ΄νŠΈλ¦¬μŠ€νŠΈμ™€ ν•¨κ»˜. type 의미 λ³€κ²½/μ‚­μ œλŠ” breaking β†’ 버전 올리고 ADR 기둝.
- μƒˆ props에 κ°œμΈμ •λ³΄Β·κ³‘ 정보(β‘ μ—μ„œ)·자유 ν…μŠ€νŠΈλ₯Ό λ„£μ§€ μ•ŠλŠ”λ‹€. 자유 ν…μŠ€νŠΈκ°€ ν•„μš”ν•˜λ©΄
버킷/μ—΄κ±°ν˜•μœΌλ‘œ λ°”κΏ” μ„€κ³„ν•œλ‹€.
Loading
Loading