Skip to content

Design — Analytics de downloads de arquivos

Status: implementado — ADR 0016 (decisões) + fases 1–9 concluídas. Desvios da implementação em relação a este design estão marcados com ⚠️ abaixo. Objetivo: dar aos downloads de arquivos a mesma paridade analítica que os vídeos já têm hoje (apps.streamingMediaPlaybackSession/MediaPlaybackHeartbeat, heatmap, watchtime; ver ADR 0009).


1. Contexto: o que existe hoje

1.1 Analytics de vídeo (paridade alvo)

CamadaOndeO quê
Modelsbackend/apps/streaming/models.pyMediaPlaybackSession (user, device, IP, total_watch_time, completion_rate, is_completed) + MediaPlaybackHeartbeat (log por ping: current_time, watch_time_delta, quality, rebuffer_events)
Endpointsbackend/apps/streaming/urls.pyPOST /api/v1/media/<uuid>/heartbeat/ (aceita sendBeacon), GET /api/v1/media/<uuid>/analytics/ (total_views, total_watch_time, average_watch_time, completion_rate, heatmap 5s)
Frontend@repo/ui video-player.tsxVideoAnalyticsConfig (analyticsUrl, mediaId, sessionId, heartbeatIntervalMs) — heartbeats a cada 5s + pause/unmount
Adminbackend/apps/streaming/admin.pyListagem de sessões/heartbeats

Gap de vídeo relacionado — resolvido pelo ADR 0016: os players de aula e de produto do SPA agora passam a prop analytics ao VideoPlayer (heartbeat em /api/v1/media/<id>/heartbeat/), e MediaAnalyticsSummaryView/GET /media/<id>/analytics/ foram restritos a admin/moderador.

1.2 Fluxos de download existentes (sem nenhum tracking)

FluxoEndpointComo entrega o arquivoPassa pelo backend?
Drive (admin)GET /api/v1/files/{uid}/download/ (apps/drive/views.py::DriveFileViewSet.download)Responde {download_url} (presigned S3/MinIO) ou FileResponse local✅ sim (gera a URL)
Anexo de aulaGET /api/headless/v1/courses/{course}/lessons/{lesson}/attachments/{media}/download (apps/headless/views.py::LessonAttachmentDownloadView)Valida paywall/acesso, marca d'água em PDFs, 302 → presigned URL✅ sim
Produto digitalGET /api/v1/product-items/{uid}/file/ (ProductItemFileView, apps/spaces) — o file_url do detail já apontava para ele ⚠️Backend serve o arquivo via FileResponse com gate de acesso (visible_to + is_accessible_to/is_free_preview)sim (já passava pelo backend — só faltava registrar o evento)

Fatos verificados:

  • DriveFile e ProductItem não têm campo de contagem de downloads (zero ocorrências de download_count/downloads no codebase).
  • Nenhum endpoint de download chama AuditLogger.log (o AuditLogEvent/AuditLogger de apps/utils/services/audit.py é genérico e não é usado em nenhum fluxo real hoje).
  • _internal/apps/tracking é infra de marketing (ingest de eventos → pixels GA4/Meta/TikTok, Neon separado, sem conceitos de domínio) — não deve ser usado para analytics de produto.

2. Decisões de design

D1. Um app novo e pequeno: apps.downloads

Download analytics é transversal a três donos (DriveFile, Media — anexos de aula, ProductItem). Não cabe em apps.drive (só cobre Drive), nem em apps.streaming (que é "métricas de reprodução", ADR 0009), nem em apps.media (é pipeline de processamento). Um app dedicado segue o padrão já consolidado de desacoplar domínio por app (streaming, payments).

D2. Um modelo único de evento: FileDownloadEvent com GenericForeignKey

Espelha o padrão já usado por Media.target/Reaction (GFK) — um único modelo serve os três alvos sem tabelas paralelas:

FileDownloadEvent(BaseModel)
├── uid (BaseModel)
├── community FK → Community (indexado)
├── target_content_type FK + target_object_id (GFK)  → DriveFile | Media | ProductItem
├── membership FK → Membership (nullable — futuros downloads anônimos/públicos)
├── ip_address GenericIPAddressField (null=True)
├── user_agent TextField (blank)
├── referrer URLField (blank)   # request.META HTTP_REFERER
├── content_type CharField      # snapshot MIME (ex.: application/pdf)
├── file_size PositiveBigIntegerField  # snapshot de bytes
└── created_at (BaseModel)

Por que evento por download e não heartbeat/sessão? Download é um evento discreto e de volume baixo-médio (diferente do heartbeat de vídeo, que pinge a cada 5s). Um log por download resolve. Sem contador denormalizado no dono: contadores são deriváveis por COUNT/agregação e ficam sempre consistentes — se no futuro o volume exigir, adiciona-se um downloads_count denormalizado com backfill (mesmo padrão de Post.reaction_count_agg no sort popular).

Dedup: não deduplicar. Re-download do mesmo membro é um evento legítimo (e informação útil: "quantos baixaram de novo"). Anti-abuso via throttle (seção 6).

Privacidade: IP/UA guardados igual MediaPlaybackSession. membership identifica o baixador (attribution via Membership, nunca via CustomUser — regra do projeto).

Em Drive e anexos de aula o backend já é o ponto único onde a presigned URL nasce — o registro acontece aí. Para produtos digitais isso exige parar de entregar file_url direto (ver D5). Trade-off assumido (já implícito em LessonAttachmentDownloadView): gerar a URL ≈ contar o download; um clique que não completa o download conta igual.

D4. Analytics restrito a admin/moderador

O atual MediaAnalyticsSummaryView exige apenas RequiresActiveMembership (qualquer membro vê métricas de qualquer mídia) — não repetir isso. Endpoints de analytics de downloads usam IsAdminOrModerator (mesmo gate do AdminAnalyticsView).

D5. Produto digital: registrar no endpoint que já serve o arquivo ⚠️

Desvio da implementação: o design previa um novo ProductItemDownloadView no headless porque assumia que o frontend baixava direto do storage via file_url presigned. Na verdade ProductPackageItemDetailView já embutia file_url = reverse("spaces:product-item-file") — ou seja, o download de produto já passava pelo backend (ProductItemFileView, GET /api/v1/product-items/{uid}/file/, que valida visible_to + is_accessible_to/is_free_preview e serve FileResponse).

  • Backend (feito): ProductItemFileView ganhou record_download(request, target=item, ...) antes de servir o arquivo — mesmo padrão dos outros fluxos. Nenhum endpoint novo, nenhuma mudança de schema/contrato.
  • Frontend (não precisou de mudança): $itemId.tsx já apontava para o backend; o TablatureViewer continua usando file_url para preview inline (que agora também conta como download — decisão D3 assumida).

3. Endpoints

3.1 Registro (implementado)

SuperfícieEndpointAção
DriveGET /api/v1/files/{uid}/download/ (existente)+ criar FileDownloadEvent antes de responder a URL
Anexo de aulaGET .../attachments/{media_id}/download (existente)+ criar FileDownloadEvent antes do 302 (e no fallback de PDF com marca d'água)
Produto digitalGET /api/v1/product-items/{uid}/file/ (já existente ⚠️)+ criar FileDownloadEvent antes do FileResponse

3.2 Sumário (novos, apps.downloads, gate admin/moderador)

Formato espelhando MediaAnalyticsSummaryView, simplificado (sem heatmap — não faz sentido para download):

json
{
  "total_downloads": 42,
  "downloads_30d": 17,
  "unique_downloaders": 12,
  "average_per_day_30d": 0.6,
  "timeline": [{"day": "2026-07-01", "count": 3}, "..."],
  "top_downloaders": [{"membership_id": "...", "name": "João", "count": 9}, "..."],
  "last_downloads": [{"at": "...", "membership_id": "...", "name": "Maria", "ip": "..."}, "..."]
}

Uma única rota genérica por GFK (mais simples de manter do que 3 endpoints espelhados):

  • GET /api/v1/downloads/analytics/?object_type=drive|media|product&object_id=<uid> → sumário acima.
    • object_id é sempre o uid (UUID) do alvo; resolução via GFK no app.
    • driveDriveFile, mediaMedia, productProductItem.
    • Alternativa (se preferir explícito por superfície): 3 endpoints GET /api/v1/files/{uid}/analytics/, GET /api/v1/media/{uid}/downloads/, GET /api/v1/products/{pid}/items/{iid}/downloads/. Recomendo o genérico — uma rota, um serializer, uma página no admin.

3.3 Comunidade (dashboard admin)

Estender AdminAnalyticsView (apps/communities/views_analytics.py) com seção files:

json
"files": {
  "total_downloads": 128,
  "top_files": [{"label": "tab.pdf", "type": "drive|media|product", "id": "...", "downloads": 34}, "..."],
  "timeline": [{"day": "...", "count": 5}, "..."]
}

Os endpoints de download (todos autenticados) resolvem request.community via TenantResolutionMiddleware — o community do evento vem de request.community.


4. Mudanças de frontend

AppMudança
apps/web produtos ($itemId.tsx)Nenhuma ⚠️ — o file_url já apontava para o backend (ProductItemFileView); o download passou a ser rastreado sem tocar no frontend
apps/web aulas ($lessonId.tsx)Nenhuma — já passa pelo backend; tracking transparente
apps/admin /analyticsNova seção "Downloads" (top arquivos + timeline), consumindo AdminAnalyticsView.filesfeito (DownloadStats.tsx)
apps/admin Drive/Media (telas de listagem)Coluna opcional com contagem de downloads por arquivo (usa o endpoint genérico por objeto) — não feito, fica como opportunity

Regra do Orval: qualquer mudança de view/serializer/schema exige make generate-api antes de codar o frontend.


5. Reuso de infra existente

  • Presigned URL: apps.media.security.get_presigned_download_url (já usada em anexos) e o padrão do DriveFileViewSet.download — reaproveitar em ProductItemDownloadView.
  • Gates de acesso: _resolve_accessible_space + visible_content_qs/resolve_accessible_object (contrato do headless, regra #0 do CLAUDE.md) para produto digital e anexos.
  • Throttling: with_default_throttles(...) e classes de apps/communities/throttling.py — aplicar aos endpoints de download e analytics.
  • AuditLogEvent: opcionalmente registrar "admin_viewed_download_analytics" (não substitui o FileDownloadEvent, que é o dado de produto).
  • BaseModel: novo modelo estende apps.utils.models.BaseModel (uid + timestamps).

6. Considerações de segurança/privacidade

  • Throttle dedicado nos endpoints de download (ex.: DownloadRateThrottle) — um crawler gerando presigned URLs inflaria os números.
  • membership nullable: se futuramente houver conteúdo público baixável, o evento continua válido sem identidade.
  • IP/UA guardados; exposição no last_downloads do sumário só para admins (já garantido pelo gate).
  • Retenção: como os playback analytics, sem política de expurgo hoje — registrar no doc técnico quando existir.

7. Fases de implementação (executadas — ADR 0016)

  1. Fase 1 — video_media_id no detail de aula (schema + respostas full/stub locked).
  2. Fase 2 — Telemetria de vídeo no SPA (analytics prop nos players de aula e produto).
  3. Fase 3 — Permissões: MediaAnalyticsSummaryViewIsAdminOrModerator (heartbeat permanece permissivo).
  4. Fase 4 — Base apps.downloads: FileDownloadEvent + migração + admin + record_download.
  5. Fase 5 — Registro nos fluxos existentes: Drive + anexos de aula.
  6. Fase 6 — Produto digital: registro no ProductItemFileView já existente ⚠️ (sem endpoint novo).
  7. Fase 7 — Sumários: GET /api/v1/downloads/analytics/ + seção files no AdminAnalyticsView (incluiu fix de bug pré-existente: [-12:] em QuerySet 500ava o dashboard).
  8. Fase 8 — UI admin: cards "Downloads" no dashboard (DownloadStats.tsx).
  9. Fase 9 — Docs: BACKEND_TECHNICAL.md, CONTEXT.md e este design doc.

Itens fora de escopo (opportunities, não bloqueiam)

  • Ligar a prop analytics do VideoPlayer no SPAfeito (Fases 1–2).
  • Contador denormalizado downloads_count nos donos (só se o volume exigir).
  • Coluna de contagem por arquivo nas telas de listagem do admin (Drive/Media) — opcional, usa o endpoint genérico por objeto.
  • Analytics de "visualização" de arquivos (abrir/preview vs baixar).
  • DownloadRateThrottle dedicado nos endpoints de download (seção 6) — não implementado no primeiro momento.

8. Perguntas em aberto — resolvidas (ADR 0016)

  1. Endpoint genérico vs por superfíciegenérico (object_type + object_id): uma rota, um serializer, uma página no admin.
  2. file_url para o TablatureViewer?mantido para preview inline; o download via ProductItemFileView também conta como evento (D3).
  3. Exibir contagem publicamente?não: contagens só no admin (gate IsAdminOrModerator). Revisitar se o produto exigir exposição pública.
  4. PR único ou por fase? → implementação por fases (1–9), agora consolidadas no ADR 0016.

Strum — Documentação.