Salva telemetria client (OS, app, device, operatore) sulle sessioni per il debug admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 09:43:43 +02:00
co-authored by Cursor
parent 873e0ea55c
commit 9d8b35c06c
24 changed files with 408 additions and 18 deletions
@@ -77,6 +77,7 @@ module Api
thermal_state: sanitized_thermal_state,
last_seen_at: Time.current
)
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
sync_publisher_when_streaming!(params[:fps].to_f)
SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content
@@ -180,7 +181,12 @@ module Api
end
def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
params.permit(
:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel,
client: %i[os client_os app_version app_build version build build_number
device_manufacturer manufacturer device_model model
os_version system_version carrier network_operator operator]
)
end
def score_sync_params
+23
View File
@@ -81,6 +81,29 @@ module AdminHelper
end
end
def admin_session_client_os_label(session)
case session.client_os.to_s
when "android" then "Android"
when "ios" then "iOS"
else I18n.t("admin.common.dash")
end
end
def admin_session_client_summary(session)
parts = []
parts << admin_session_client_os_label(session) if session.client_os.present?
if session.app_version.present?
ver = session.app_version
ver = "#{ver} (#{session.app_build})" if session.app_build.present?
parts << "app #{ver}"
end
device = [session.device_manufacturer, session.device_model].compact_blank.join(" ")
parts << device if device.present?
parts << "OS #{session.os_version}" if session.os_version.present?
parts << session.carrier if session.carrier.present?
parts.presence&.join(" · ") || I18n.t("admin.common.dash")
end
def admin_format_event_meta(metadata)
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
@@ -0,0 +1,72 @@
# frozen_string_literal: true
module Sessions
# Normalizza e applica fingerprint del client (OS, app, device, operatore)
# sulla sessione, a create e/o a ogni telemetry.
class ApplyClientInfo
OS_VALUES = %w[android ios].freeze
MAX_LEN = 80
ATTRS = %i[
client_os app_version app_build device_manufacturer device_model os_version carrier
].freeze
def self.call(session, raw)
new(session, raw).call
end
def initialize(session, raw)
@session = session
@raw = normalize_hash(raw)
end
def call
attrs = extract_attrs
return @session if attrs.empty?
@session.assign_attributes(attrs)
@session.save! if @session.persisted? && @session.changed?
@session
end
private
def normalize_hash(raw)
return {} if raw.blank?
data = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
data = data.to_h if data.respond_to?(:to_h)
data.with_indifferent_access
rescue StandardError
{}
end
def extract_attrs
attrs = {}
os = @raw[:os].presence || @raw[:client_os].presence
os = os.to_s.downcase.strip
attrs[:client_os] = os if OS_VALUES.include?(os)
{
app_version: %i[app_version version],
app_build: %i[app_build build build_number],
device_manufacturer: %i[device_manufacturer manufacturer],
device_model: %i[device_model model],
os_version: %i[os_version system_version],
carrier: %i[carrier network_operator operator]
}.each do |column, keys|
value = keys.map { |k| @raw[k] }.find(&:present?)
next if value.blank?
attrs[column] = truncate(value.to_s.strip)
end
attrs
end
def truncate(value)
value.bytesize <= MAX_LEN ? value : value.byteslice(0, MAX_LEN)
end
end
end
+6 -2
View File
@@ -26,6 +26,7 @@ module Sessions
target_fps: @params[:target_fps] || 30,
status: "idle"
)
Sessions::ApplyClientInfo.call(session, @params[:client])
youtube_channel = nil
if session.platform == "youtube"
@@ -46,8 +47,11 @@ module Sessions
{
created: true,
platform: session.platform,
stream_node: session.stream_node&.slug
}
stream_node: session.stream_node&.slug,
client_os: session.client_os,
app_version: session.app_version,
device_model: session.device_model
}.compact
)
end
@@ -107,6 +107,7 @@
<tr>
<th><%= t("admin.dashboard.sessions.table.match") %></th>
<th><%= t("admin.dashboard.sessions.table.status") %></th>
<th><%= t("admin.dashboard.sessions.table.client") %></th>
<th><%= t("admin.dashboard.sessions.table.ingest") %></th>
<th><%= t("admin.dashboard.sessions.table.start") %></th>
<th><%= t("admin.dashboard.sessions.table.link") %></th>
@@ -118,6 +119,7 @@
<tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td class="muted"><%= admin_session_client_summary(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
<td>
@@ -83,6 +83,7 @@
<th><%= t("admin.sessions.index.table.ended") %></th>
<th><%= t("admin.sessions.index.table.duration") %></th>
<th><%= t("admin.sessions.index.table.ingest") %></th>
<th><%= t("admin.sessions.index.table.client") %></th>
<th><%= t("admin.sessions.index.table.disconnects") %></th>
<th><%= t("admin.sessions.index.table.link") %></th>
<th></th>
@@ -110,6 +111,7 @@
<td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td>
<td class="muted"><%= admin_session_duration_label(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted admin-table-sub"><%= admin_session_client_summary(s) %></td>
<td><%= s.disconnection_count %></td>
<td>
<div class="admin-link-compact">
@@ -67,6 +67,38 @@
<dt><%= t("admin.sessions.show.fields.platform") %></dt>
<dd><%= @session.platform %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.client_os") %></dt>
<dd><%= admin_session_client_os_label(@session) %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.app_version") %></dt>
<dd>
<% if @session.app_version.present? %>
<%= @session.app_version %>
<% if @session.app_build.present? %>
<span class="muted">(<%= @session.app_build %>)</span>
<% end %>
<% else %>
<%= t("admin.common.dash") %>
<% end %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.device") %></dt>
<dd>
<% device = [@session.device_manufacturer, @session.device_model].compact_blank.join(" ") %>
<%= device.presence || t("admin.common.dash") %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.os_version") %></dt>
<dd><%= @session.os_version.presence || t("admin.common.dash") %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.carrier") %></dt>
<dd><%= @session.carrier.presence || t("admin.common.dash") %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.privacy") %></dt>
<dd><%= @session.privacy_status %></dd>
+7
View File
@@ -111,6 +111,7 @@ de:
table:
match: Spiel
status: Status
client: Client
ingest: Ingest
start: Start
link: Link
@@ -276,6 +277,7 @@ de:
duration: Dauer
ingest: Ingest
disconnects: Verbindungsabbrüche
client: Client
link: Link
detail: Details
regia: Regie
@@ -308,6 +310,11 @@ de:
opponent: Gegner
operator: Operator
platform: Plattform
client_os: System
app_version: App-Version
device: Gerät
os_version: OS-Version
carrier: Mobilfunkanbieter
privacy: Privacy
quality: Qualität
min_quality: Min. Qualität
+7
View File
@@ -111,6 +111,7 @@ en:
table:
match: Match
status: Status
client: Client
ingest: Ingest
start: Start
link: Link
@@ -276,6 +277,7 @@ en:
duration: Duration
ingest: Ingest
disconnects: Disconnects
client: Client
link: Link
detail: Details
regia: Control
@@ -308,6 +310,11 @@ en:
opponent: Opponent
operator: Operator
platform: Platform
client_os: OS
app_version: App version
device: Device
os_version: OS version
carrier: Carrier
privacy: Privacy
quality: Quality
min_quality: Min quality
+7
View File
@@ -111,6 +111,7 @@ es:
table:
match: Partido
status: Estado
client: Cliente
ingest: Ingest
start: Inicio
link: Enlace
@@ -276,6 +277,7 @@ es:
duration: Duración
ingest: Ingest
disconnects: Desconexiones
client: Cliente
link: Enlace
detail: Detalle
regia: Regie
@@ -308,6 +310,11 @@ es:
opponent: Rival
operator: Operador
platform: Plataforma
client_os: Sistema
app_version: Versión app
device: Dispositivo
os_version: Versión OS
carrier: Operador móvil
privacy: Privacidad
quality: Calidad
min_quality: Calidad mínima
+7
View File
@@ -111,6 +111,7 @@ fr:
table:
match: Match
status: Statut
client: Client
ingest: Ingest
start: Début
link: Lien
@@ -276,6 +277,7 @@ fr:
duration: Durée
ingest: Ingest
disconnects: Déconnexions
client: Client
link: Lien
detail: Détail
regia: Régie
@@ -308,6 +310,11 @@ fr:
opponent: Adversaire
operator: Opérateur
platform: Plateforme
client_os: Système
app_version: Version app
device: Appareil
os_version: Version OS
carrier: Opérateur mobile
privacy: Confidentialité
quality: Qualité
min_quality: Qualité mini
+7
View File
@@ -115,6 +115,7 @@ it:
table:
match: Partita
status: Stato
client: Client
ingest: Ingest
start: Inizio
link: Link
@@ -297,6 +298,7 @@ it:
duration: Durata
ingest: Ingest
disconnects: Disconnessioni
client: Client
link: Link
detail: Dettaglio
regia: Regia
@@ -329,6 +331,11 @@ it:
opponent: Avversario
operator: Operatore
platform: Piattaforma
client_os: Sistema
app_version: Versione app
device: Dispositivo
os_version: Versione OS
carrier: Operatore telefonico
privacy: Privacy
quality: Qualità
min_quality: Qualità minima
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddClientTelemetryToStreamSessions < ActiveRecord::Migration[7.2]
def change
change_table :stream_sessions, bulk: true do |t|
t.string :client_os
t.string :app_version
t.string :app_build
t.string :device_manufacturer
t.string :device_model
t.string :os_version
t.string :carrier
end
end
end
+8 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
ActiveRecord::Schema[7.2].define(version: 2026_08_26_090000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -427,6 +427,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", null: false
t.boolean "audio_muted", default: false, null: false
t.string "client_os"
t.string "app_version"
t.string "app_build"
t.string "device_manufacturer"
t.string "device_model"
t.string "os_version"
t.string "carrier"
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
@@ -0,0 +1,45 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Sessions::ApplyClientInfo do
let!(:user) { User.create!(email: "client-info@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle") }
it "applica i campi client sulla sessione" do
described_class.call(session, {
os: "android",
app_version: "1.4.0",
app_build: "42",
device_manufacturer: "Samsung",
device_model: "SM-G991B",
os_version: "14",
carrier: "TIM"
})
session.reload
expect(session.client_os).to eq("android")
expect(session.app_version).to eq("1.4.0")
expect(session.app_build).to eq("42")
expect(session.device_manufacturer).to eq("Samsung")
expect(session.device_model).to eq("SM-G991B")
expect(session.os_version).to eq("14")
expect(session.carrier).to eq("TIM")
end
it "ignora os sconosciuti" do
described_class.call(session, { os: "windows" })
expect(session.reload.client_os).to be_nil
end
it "assegna senza salvare su record non persistito" do
draft = StreamSession.new(match: match, user: user, platform: "matchlivetv", status: "idle")
described_class.call(draft, { os: "ios", app_version: "2.0.0" })
expect(draft).not_to be_persisted
expect(draft.client_os).to eq("ios")
expect(draft.app_version).to eq("2.0.0")
end
end