glean/src/core/pings/maker.ts (194 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import type { ClientInfo, PingInfo, PingPayload } from "../pings/ping_payload.js";
import type CommonPingData from "./common_ping_data.js";
import { Context } from "../context.js";
import log, { LoggingLevel } from "../log.js";
import TimeUnit from "../metrics/time_unit.js";
import { Lifetime } from "../metrics/lifetime.js";
import { CounterMetric } from "../metrics/types/counter.js";
import { InternalCounterMetricType as CounterMetricType } from "../metrics/types/counter.js";
import { DatetimeMetric } from "../metrics/types/datetime.js";
import { InternalDatetimeMetricType as DatetimeMetricType } from "../metrics/types/datetime.js";
import { GLEAN_VERSION, PING_INFO_STORAGE, CLIENT_INFO_STORAGE, GLEAN_SCHEMA_VERSION } from "../constants.js";
const PINGS_MAKER_LOG_TAG = "core.Pings.Maker";
/// INTERFACES ///
export interface StartTimeMetricData {
startTimeMetric: DatetimeMetricType;
startTime: DatetimeMetric;
}
/// HELPERS ///
/**
* Build a pings submission path.
*
* @param identifier The pings UUID identifier.
* @param ping The ping to build a path for.
* @returns The final submission path.
*/
export function makePath(identifier: string, ping: CommonPingData): string {
// We are sure that the applicationId is not `undefined` at this point,
// this function is only called when submitting a ping
// and that function return early when Glean is not initialized.
return `/submit/${Context.applicationId}/${ping.name}/${GLEAN_SCHEMA_VERSION}/${identifier}`;
}
/**
* Gathers all the headers to be included to the final ping request.
*
* This guarantees that if headers are disabled after the ping collection,
* ping submission will still contain the desired headers.
*
* The current headers gathered here are:
* - [X-Debug-ID]
* - [X-Source-Tags]
*
* @returns An object containing all the headers and their values
* or `undefined` in case no custom headers were set.
*/
export function getPingHeaders(): Record<string, string> | undefined {
const headers: Record<string, string> = {};
if (Context.config.debugViewTag) {
headers["X-Debug-ID"] = Context.config.debugViewTag;
}
if (Context.config.sourceTags) {
headers["X-Source-Tags"] = Context.config.sourceTags.toString();
}
if (Object.keys(headers).length > 0) {
return headers;
}
}
/**
* Gets the start time metric and its currently stored data.
*
* @param ping The ping for which we want to get the times.
* @returns An object containing the start time metric and its value.
*/
function getStartTimeMetricAndData(ping: CommonPingData): StartTimeMetricData {
const startTimeMetric = new DatetimeMetricType(
{
category: "",
name: `${ping.name}#start`,
sendInPings: [PING_INFO_STORAGE],
lifetime: Lifetime.User,
disabled: false
},
TimeUnit.Minute
);
// "startTime" is the time the ping was generated the last time.
// If not available, we use the date the Glean object was initialized.
const startTimeData = Context.metricsDatabase.getMetric(
PING_INFO_STORAGE,
startTimeMetric
);
let startTime: DatetimeMetric;
if (startTimeData) {
startTime = new DatetimeMetric(startTimeData);
} else {
startTime = DatetimeMetric.fromDate(Context.startTime, TimeUnit.Minute);
}
return {
startTimeMetric,
startTime
};
}
/**
* Gets, and then increments, the sequence number for a given ping.
*
* @param ping The ping for which we want to get the sequence number.
* @returns The current number (before incrementing).
*/
export function getSequenceNumber(ping: CommonPingData): number {
const seq = new CounterMetricType({
category: "",
name: `${ping.name}#sequence`,
sendInPings: [PING_INFO_STORAGE],
lifetime: Lifetime.User,
disabled: false
});
const currentSeqData = Context.metricsDatabase.getMetric(
PING_INFO_STORAGE,
seq
);
seq.add(1);
if (currentSeqData) {
// Creating a new counter metric validates that the metric stored is actually a number.
// When we `add` we deal with getting rid of that number from storage,
// no need to worry about that here.
try {
const metric = new CounterMetric(currentSeqData);
return metric.payload();
} catch (e) {
log(
PINGS_MAKER_LOG_TAG,
`Unexpected value found for sequence number in ping ${ping.name}. Ignoring.`,
LoggingLevel.Warn
);
}
}
return 0;
}
/**
* Gets the formatted start and end times for this ping
* and updates for the next ping.
*
* @param ping The ping for which we want to get the times.
* @returns An object containing start and times in their payload format.
*/
export function getStartEndTimes(ping: CommonPingData): { startTime: string; endTime: string } {
const { startTimeMetric, startTime } = getStartTimeMetricAndData(ping);
// Update the start time with the current time.
const endTimeData = new Date();
startTimeMetric.set(endTimeData);
const endTime = DatetimeMetric.fromDate(endTimeData, TimeUnit.Minute);
return {
startTime: startTime.payload(),
endTime: endTime.payload()
};
}
/**
* Builds the `ping_info` section of a ping.
*
* @param ping The ping to build the `ping_info` section for.
* @param reason The reason for submitting this ping.
* @returns The final `ping_info` section in its payload format.
*/
export function buildPingInfoSection(ping: CommonPingData, reason?: string): PingInfo {
const seq = getSequenceNumber(ping);
const { startTime, endTime } = getStartEndTimes(ping);
const pingInfo: PingInfo = {
seq,
start_time: startTime,
end_time: endTime
};
if (reason) {
pingInfo.reason = reason;
}
return pingInfo;
}
/**
* Builds the `client_info` section of a ping.
*
* @param ping The ping to build the `client_info` section for.
* @returns The final `client_info` section in its payload format.
*/
export function buildClientInfoSection(ping: CommonPingData): ClientInfo {
let clientInfo = Context.metricsDatabase.getPingMetrics(
CLIENT_INFO_STORAGE,
true
);
if (!clientInfo) {
// TODO: Watch Bug 1685705 and change behaviour in here accordingly.
log(PINGS_MAKER_LOG_TAG, "Empty client info data. Will submit anyways.", LoggingLevel.Warn);
clientInfo = {};
}
let finalClientInfo: ClientInfo = {
telemetry_sdk_build: GLEAN_VERSION
};
for (const metricType in clientInfo) {
finalClientInfo = { ...finalClientInfo, ...clientInfo[metricType] };
}
if (!ping.includeClientId) {
delete finalClientInfo["client_id"];
// If the ping doesn't include the client_id, we also should exclude session_id.
delete finalClientInfo["session_id"];
}
return finalClientInfo;
}
/**
* Collects a snapshot for the given ping from storage and attach required meta information.
*
* @param ping The ping to collect for.
* @param reason An optional reason code to include in the ping.
* @returns A fully assembled JSON representation of the ping payload.
* If there is no data stored for the ping, `undefined` is returned.
*/
export function collectPing(ping: CommonPingData, reason?: string): PingPayload | undefined {
// !IMPORTANT! Events data needs to be collected BEFORE other metrics,
// because events collection may result in recording of error metrics.
const eventsData = Context.eventsDatabase.getPingEvents(ping.name, true);
let metricsData = Context.metricsDatabase.getPingMetrics(
ping.name,
true
);
if (!metricsData && !eventsData) {
if (!ping.sendIfEmpty) {
log(PINGS_MAKER_LOG_TAG, `Storage for ${ping.name} empty. Bailing out.`, LoggingLevel.Info);
return;
}
log(
PINGS_MAKER_LOG_TAG,
`Storage for ${ping.name} empty. Ping will still be sent.`,
LoggingLevel.Info
);
}
// Insert the experimentation id if the metrics aren't empty
if (ping.includeClientId && Context.config.experimentationId) {
if (metricsData !== undefined) {
metricsData = {
...metricsData,
string: {
...metricsData?.string || undefined,
"glean.client.annotation.experimentation_id": Context.config.experimentationId
}
};
} else {
metricsData = {
"string": {
"glean.client.annotation.experimentation_id": Context.config.experimentationId
}
};
}
}
const metrics = metricsData ? { metrics: metricsData } : {};
const events = eventsData ? { events: eventsData } : {};
const pingInfo = buildPingInfoSection(ping, reason);
const clientInfo = buildClientInfoSection(ping);
return {
...metrics,
...events,
ping_info: pingInfo,
client_info: clientInfo
};
}
/**
* Collects and stores a ping on the pings database.
*
* This function will trigger the `AfterPingCollection` event.
* This event is triggered **after** logging the ping, which happens if `logPings` is set.
* We will log the payload before it suffers any change by plugins listening to this event.
*
* @param identifier The pings UUID identifier.
* @param ping The ping to submit.
* @param reason An optional reason code to include in the ping.
*/
export function collectAndStorePing(
identifier: string,
ping: CommonPingData,
reason?: string
): void {
const collectedPayload = collectPing(ping, reason);
if (!collectedPayload) {
return;
}
if (Context.config.logPings) {
log(PINGS_MAKER_LOG_TAG, JSON.stringify(collectedPayload, null, 2), LoggingLevel.Info);
}
const headers = getPingHeaders();
Context.pingsDatabase.recordPing(
makePath(identifier, ping),
identifier,
collectedPayload,
headers
);
}
export default collectAndStorePing;