/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/app/src/MetricsManager.ts
348 строк
10 KB
Ken McGrady
Update copyright header to 2026 (#13491)
02 янв 2026, 16:21
Не верифицирован
02 янв 2026, 16:21
374540a
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { pick } from "lodash-es" import { getLogger } from "loglevel" import { v4 as uuidv4 } from "uuid" import { IS_DEV_ENV } from "@streamlit/connection" import { DeployedAppMetadata, IGuestToHostMessage, SessionInfo, setCookie, } from "@streamlit/lib" import { IMetricsEvent, MetricsEvent } from "@streamlit/protobuf" import { getCookie, localStorageAvailable } from "@streamlit/utils" // Default metrics config fetched when none provided by host config endpoint export const DEFAULT_METRICS_CONFIG = "https://data.streamlit.io/metrics.json" const LOG = getLogger("MetricsManager") type EventName = "viewReport" | "updateReport" | "pageProfile" | "menuClick" type Event = [EventName, Partial<IMetricsEvent>] export class MetricsManager { /** The app's SessionInfo instance. */ private readonly sessionInfo: SessionInfo private initialized = false /** * Whether to send metrics to the server. */ private actuallySendMetrics = false /** * The URL to which metrics are sent. */ private metricsUrl: string | undefined = undefined /** * Function to send a message to the host via postMessage communication */ private sendMessageToHost: (message: IGuestToHostMessage) => void = () => {} /** * The anonymous ID of the user. */ private anonymousId = "" /** * Queue of metrics events that were enqueued before this MetricsManager was * initialized. */ private pendingEvents: Event[] = [] /** * App hash uniquely identifies "projects" so we can tell * how many projects are being created with Streamlit while still keeping * possibly-sensitive info like the mainScriptPath outside of our metrics * services. */ private appHash = "Not initialized" private metadata: DeployedAppMetadata = {} public constructor(sessionInfo: SessionInfo) { this.sessionInfo = sessionInfo } public async initialize({ gatherUsageStats, sendMessageToHost, }: { gatherUsageStats: boolean sendMessageToHost: (message: IGuestToHostMessage) => void }): Promise<void> { this.sendMessageToHost = sendMessageToHost // Handle if the user or the host has disabled metrics this.actuallySendMetrics = gatherUsageStats && this.metricsUrl !== "off" this.getAnonymousId() // Trigger fallback to fetch default metrics config if not provided by host if (this.actuallySendMetrics && !this.metricsUrl) { await this.requestDefaultMetricsConfig() // If metricsUrl still undefined, deactivate metrics if (!this.metricsUrl) { LOG.error("Undefined metrics config - deactivating metrics tracking.") this.actuallySendMetrics = false } } if (this.actuallySendMetrics) { this.sendPendingEvents() } LOG.info("Gather usage stats: ", this.actuallySendMetrics) this.initialized = true } public enqueue( evName: EventName, evData: Partial<IMetricsEvent> = {} ): void { if (!this.initialized || !this.sessionInfo.isSet) { this.pendingEvents.push([evName, evData]) return } if (!this.actuallySendMetrics) { return } if (this.pendingEvents.length) { this.sendPendingEvents() } this.send(evName, evData) } // App hash gets set when updateReport happens. // This means that it will be attached to most, but not all, metrics events. // The viewReport and createReport events are sent before updateReport happens, // so they will not include the appHash. public setAppHash = (appHash: string): void => { this.appHash = appHash } // Set metrics url if sent by the host_config public setMetricsConfig = (metricsUrl = ""): void => { this.metricsUrl = metricsUrl } public setMetadata(metadata: DeployedAppMetadata): void { this.metadata = metadata } // Fallback - Checks if cached in localStorage, otherwise fetches the config from a default URL // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. private async requestDefaultMetricsConfig(): Promise<any> { const isLocalStoreAvailable = localStorageAvailable() if (isLocalStoreAvailable) { const cachedConfig = window.localStorage.getItem("stMetricsConfig") if (cachedConfig) { this.metricsUrl = cachedConfig return } } try { const response = await fetch(DEFAULT_METRICS_CONFIG, { signal: AbortSignal.timeout(5000), }) if (!response.ok) { this.metricsUrl = undefined LOG.error("Failed to fetch metrics config: ", response.status) } else { const data = await response.json() this.metricsUrl = data.url ?? undefined if (isLocalStoreAvailable && this.metricsUrl) { window.localStorage.setItem("stMetricsConfig", this.metricsUrl) } } } catch (err) { LOG.error("Failed to fetch metrics config:", err) } } // The schema of metrics events (including key names and value types) should // only be changed when requested by the data team. This is why `reportHash` // retains its old name. private send(evName: EventName, evData: Partial<IMetricsEvent> = {}): void { const data = this.buildEventProto(evName, evData) // Don't actually track events when in dev mode, just print them instead. if (IS_DEV_ENV) { LOG.info("[Dev mode] Not tracking stat datapoint: ", evName, data) } else if (this.metricsUrl === "postMessage") { this.postMessageEvent(evName, data) } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises -- TODO: Fix this this.track(data) } } private sendPendingEvents(): void { this.pendingEvents.forEach(([evName, evData]) => { this.send(evName, evData) }) this.pendingEvents = [] } private async track(data: MetricsEvent): Promise<void> { // Send the event to the metrics URL // @ts-expect-error - send func calls track & checks metricsUrl defined const request = new Request(this.metricsUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data.toJSON()), }) await fetch(request) } // Helper to send metrics events to host private postMessageEvent(eventName: EventName, data: MetricsEvent): void { this.sendMessageToHost({ type: "METRICS_EVENT", eventName, data, }) } // Helper to build the event proto private buildEventProto( evName: EventName, data: Partial<IMetricsEvent> ): MetricsEvent { const eventProto = new MetricsEvent({ event: evName, anonymousId: this.anonymousId, ...this.getHostTrackingData(), ...this.getInstallationData(), reportHash: this.appHash, dev: IS_DEV_ENV, source: "browser", streamlitVersion: this.sessionInfo.current.streamlitVersion, isHello: this.sessionInfo.isHello, appId: this.sessionInfo.current.appId, sessionId: this.sessionInfo.current.sessionId, pythonVersion: this.sessionInfo.current.pythonVersion, serverOs: this.sessionInfo.current.serverOS, hasDisplay: this.sessionInfo.current.hasDisplay, isWebdriver: isWebdriver(), ...this.getContextData(), }) if (evName === "menuClick") { eventProto.label = data.label as string } else if (evName === "pageProfile") { return new MetricsEvent({ ...eventProto, ...data }) } return eventProto } // Get the installation IDs from the session private getInstallationData(): Partial<IMetricsEvent> { return { machineIdV3: this.sessionInfo.current.installationIdV3, machineIdV4: this.sessionInfo.current.installationIdV4, } } // Use the tracking data injected by the host of the app if included. private getHostTrackingData(): DeployedAppMetadata { if (this.metadata) { return pick(this.metadata, [ "hostedAt", "owner", "repo", "branch", "mainModule", "creatorId", ]) } return {} } // Get context data for events private getContextData(): Partial<IMetricsEvent> { return { contextPageUrl: window.location.href, contextPageTitle: document.title, contextPagePath: window.location.pathname, contextPageReferrer: document.referrer, contextPageSearch: window.location.search, contextLocale: window.navigator.language, contextUserAgent: window.navigator.userAgent, } } /** * Get/Create user's anonymous ID * Checks if existing in cookie or localStorage, otherwise generates * a new UUID and stores it in both. */ private getAnonymousId(): void { // If metrics disabled, anonymous ID unnecessary if (!this.actuallySendMetrics) return const anonymousIdKey = "ajs_anonymous_id" const isLocalStoreAvailable = localStorageAvailable() const anonymousIdCookie = getCookie(anonymousIdKey) const anonymousIdLocalStorage = isLocalStoreAvailable ? window.localStorage.getItem(anonymousIdKey) : null const expiration = new Date() expiration.setFullYear(new Date().getFullYear() + 1) if (anonymousIdCookie) { this.anonymousId = anonymousIdCookie if (isLocalStoreAvailable) { window.localStorage.setItem(anonymousIdKey, anonymousIdCookie) } } else if (anonymousIdLocalStorage) { try { // parse handles legacy anonymousId logic with excess quotes this.anonymousId = JSON.parse(anonymousIdLocalStorage) } catch { // if parse fails, anonymousId is not legacy and we can use as is this.anonymousId = anonymousIdLocalStorage } setCookie(anonymousIdKey, this.anonymousId, expiration) } else { this.anonymousId = uuidv4() setCookie(anonymousIdKey, this.anonymousId, expiration) if (isLocalStoreAvailable) { window.localStorage.setItem(anonymousIdKey, this.anonymousId) } } } } function isWebdriver(): boolean { return window.navigator?.webdriver ?? false }