diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bfbf669f..6124b6a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ [Full changelog](https://github.com/mozilla/glean.js/compare/v0.10.2...main) * [#202](https://github.com/mozilla/glean.js/pull/202): Add a testing API for the ping type. +* [#253](https://github.com/mozilla/glean.js/pull/253): Implement the timespan metric type. # v0.10.2 (2021-04-26) diff --git a/glean/src/core/glean.ts b/glean/src/core/glean.ts index 763ead6c3..e7bbb18c9 100644 --- a/glean/src/core/glean.ts +++ b/glean/src/core/glean.ts @@ -12,8 +12,7 @@ import { isUndefined, sanitizeApplicationId } from "./utils.js"; import { CoreMetrics } from "./internal_metrics.js"; import EventsDatabase from "./metrics/events_database.js"; import UUIDMetricType from "./metrics/types/uuid.js"; -import DatetimeMetricType from "./metrics/types/datetime.js"; -import { DatetimeMetric } from "./metrics/types/datetime_metric.js"; +import DatetimeMetricType, { DatetimeMetric } from "./metrics/types/datetime.js"; import CorePings from "./internal_pings.js"; import { registerPluginToEvent, testResetEvents } from "./events/utils.js"; diff --git a/glean/src/core/metrics/database.ts b/glean/src/core/metrics/database.ts index 1767a496c..38ebdc718 100644 --- a/glean/src/core/metrics/database.ts +++ b/glean/src/core/metrics/database.ts @@ -5,11 +5,11 @@ import type Store from "../storage/index.js"; import type { MetricType } from "./index.js"; import type { Metric } from "./metric.js"; -import { createMetric, validateMetricInternalRepresentation } from "./utils.js"; +import type { StorageBuilder } from "../../platform/index.js"; +import type { Metrics } from "./index.js"; import type { JSONObject, JSONValue } from "../utils.js"; +import { createMetric, validateMetricInternalRepresentation } from "./utils.js"; import { isObject, isUndefined } from "../utils.js"; -import type { StorageBuilder } from "../../platform/index.js"; -import type { Metrics } from "./metrics_interface"; import { Lifetime } from "./lifetime.js"; /** diff --git a/glean/src/core/metrics/index.ts b/glean/src/core/metrics/index.ts index 3ce2dc7e2..78eafa846 100644 --- a/glean/src/core/metrics/index.ts +++ b/glean/src/core/metrics/index.ts @@ -3,11 +3,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { JSONValue } from "../utils.js"; -import { isUndefined } from "../utils.js"; -import { Metric } from "./metric.js"; import type { Lifetime } from "./lifetime.js"; import type MetricsDatabase from "./database.js"; -import { getValidDynamicLabel } from "./types/labeled_utils.js"; +import { isUndefined } from "../utils.js"; +import { getValidDynamicLabel } from "./types/labeled.js"; + +export interface Metrics { + [aMetricType: string]: { + [aMetricIdentifier: string]: JSONValue; + }; +} + /** * The common set of data shared across all different metric types. @@ -103,27 +109,3 @@ export abstract class MetricType implements CommonMetricData { return (uploadEnabled && !this.disabled); } } - -/** - * This is an internal metric representation for labeled metrics. - * - * This can be used to instruct the validators to simply report - * whatever is stored internally, without performing any specific - * validation. - * - * This needs to live here, instead of labeled.ts, in order to avoid - * a cyclic dependency. - */ -export class LabeledMetric extends Metric { - constructor(v: unknown) { - super(v); - } - - validate(v: unknown): v is JSONValue { - return true; - } - - payload(): JSONValue { - return this._inner; - } -} diff --git a/glean/src/core/metrics/metrics_interface.ts b/glean/src/core/metrics/metrics_interface.ts deleted file mode 100644 index e52e88ebe..000000000 --- a/glean/src/core/metrics/metrics_interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* 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 { JSONValue } from "../utils.js"; - -export interface Metrics { - [aMetricType: string]: { - [aMetricIdentifier: string]: JSONValue; - }; -} diff --git a/glean/src/core/metrics/types/boolean.ts b/glean/src/core/metrics/types/boolean.ts index 071848bc7..20597b199 100644 --- a/glean/src/core/metrics/types/boolean.ts +++ b/glean/src/core/metrics/types/boolean.ts @@ -4,8 +4,23 @@ import type { CommonMetricData } from "../index.js"; import { MetricType } from "../index.js"; -import { BooleanMetric } from "./boolean_metric.js"; import { Context } from "../../context.js"; +import { Metric } from "../metric.js"; +import { isBoolean } from "../../utils.js"; + +export class BooleanMetric extends Metric { + constructor(v: unknown) { + super(v); + } + + validate(v: unknown): v is boolean { + return isBoolean(v); + } + payload(): boolean { + return this._inner; + } +} + /** * A boolean metric. diff --git a/glean/src/core/metrics/types/boolean_metric.ts b/glean/src/core/metrics/types/boolean_metric.ts deleted file mode 100644 index f2bf3e03e..000000000 --- a/glean/src/core/metrics/types/boolean_metric.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* 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 { Metric } from "../metric.js"; -import { isBoolean } from "../../utils.js"; - -export class BooleanMetric extends Metric { - constructor(v: unknown) { - super(v); - } - - validate(v: unknown): v is boolean { - return isBoolean(v); - } - payload(): boolean { - return this._inner; - } -} diff --git a/glean/src/core/metrics/types/counter.ts b/glean/src/core/metrics/types/counter.ts index e9b099955..a2644b9ba 100644 --- a/glean/src/core/metrics/types/counter.ts +++ b/glean/src/core/metrics/types/counter.ts @@ -3,11 +3,33 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { CommonMetricData } from "../index.js"; -import { MetricType } from "../index.js"; import type { JSONValue } from "../../utils.js"; -import { isUndefined } from "../../utils.js"; -import { CounterMetric } from "./counter_metric.js"; +import { MetricType } from "../index.js"; +import { isUndefined, isNumber } from "../../utils.js"; import { Context } from "../../context.js"; +import { Metric } from "../metric.js"; + +export class CounterMetric extends Metric { + constructor(v: unknown) { + super(v); + } + + validate(v: unknown): v is number { + if (!isNumber(v)) { + return false; + } + + if (v <= 0) { + return false; + } + + return true; + } + + payload(): number { + return this._inner; + } +} /** * A counter metric. diff --git a/glean/src/core/metrics/types/counter_metric.ts b/glean/src/core/metrics/types/counter_metric.ts deleted file mode 100644 index 368a343d3..000000000 --- a/glean/src/core/metrics/types/counter_metric.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* 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 { Metric } from "../metric.js"; -import { isNumber } from "../../utils.js"; - -export class CounterMetric extends Metric { - constructor(v: unknown) { - super(v); - } - - validate(v: unknown): v is number { - if (!isNumber(v)) { - return false; - } - - if (v <= 0) { - return false; - } - - return true; - } - - payload(): number { - return this._inner; - } -} diff --git a/glean/src/core/metrics/types/datetime.ts b/glean/src/core/metrics/types/datetime.ts index 5f03d842a..02e75f904 100644 --- a/glean/src/core/metrics/types/datetime.ts +++ b/glean/src/core/metrics/types/datetime.ts @@ -5,9 +5,157 @@ import type { CommonMetricData } from "../index.js"; import { MetricType } from "../index.js"; import TimeUnit from "../../metrics/time_unit.js"; -import type { DatetimeInternalRepresentation} from "./datetime_metric.js"; -import { DatetimeMetric } from "./datetime_metric.js"; import { Context } from "../../context.js"; +import { Metric } from "../metric.js"; +import { isNumber, isObject, isString } from "../../utils.js"; + +/** + * Builds the formatted timezone offset string frim a given timezone. + * + * The format of the resulting string is `+02:00`. + * + * @param timezone A number representing the timezone offset to format, + * this is expected to be in minutes. + * + * @returns The formatted timezone offset string. + */ +export function formatTimezoneOffset(timezone: number): string { + const offset = (timezone / 60) * -1; + const sign = offset > 0 ? "+" : "-"; + const hours = Math.abs(offset).toString().padStart(2, "0"); + return `${sign}${hours}:00`; +} + +export type DatetimeInternalRepresentation = { + // The time unit of the metric type at the time of recording. + timeUnit: TimeUnit, + // This timezone should be the exact output of `Date.getTimezoneOffset` + // and as such it should alwaye be in minutes. + timezone: number, + // This date string should be the exact output of `Date.toISOString` + // and as such it is always in UTC. + date: string, +}; + +export class DatetimeMetric extends Metric { + constructor(v: unknown) { + super(v); + } + + static fromDate(v: Date, timeUnit: TimeUnit): DatetimeMetric { + return new DatetimeMetric({ + timeUnit, + timezone: v.getTimezoneOffset(), + date: v.toISOString() + }); + } + + /** + * Gets the datetime data as a Date object. + * + * # Note + * + * The object created here will be relative to local time. + * If the timezone at the time of recording is different, + * the timezone offset will be applied before transforming to an object. + * + * @returns A date object. + */ + get date(): Date { + return new Date(this._inner.date); + } + + private get timezone(): number { + return this._inner.timezone; + } + + private get timeUnit(): TimeUnit { + return this._inner.timeUnit; + } + + private get dateISOString(): string { + return this._inner.date; + } + + validate(v: unknown): v is DatetimeInternalRepresentation { + if (!isObject(v) || Object.keys(v).length !== 3) { + return false; + } + + const timeUnitVerification = "timeUnit" in v && isString(v.timeUnit) && Object.values(TimeUnit).includes(v.timeUnit as TimeUnit); + const timezoneVerification = "timezone" in v && isNumber(v.timezone); + const dateVerification = "date" in v && isString(v.date) && v.date.length === 24 && !isNaN(Date.parse(v.date)); + if (!timeUnitVerification || !timezoneVerification || !dateVerification) { + return false; + } + + return true; + } + + /** + * Gets this metrics value in its payload representation. + * + * For this metric, the payload is the timezone aware ISO date string truncated to the time unit + * given at the time of recording. + * + * # Note + * + * The timezone of the final string is the timezone at the time of recording. + * + * @returns The metric value. + */ + payload(): string { + const extractedDateInfo = this.dateISOString.match(/\d+/g); + if (!extractedDateInfo || extractedDateInfo.length < 0) { + // This is impossible because the date is always validated before setting + throw new Error("IMPOSSIBLE: Unable to extract date information from DatetimeMetric."); + } + const correctedDate = new Date( + /* year */ parseInt(extractedDateInfo[0]), + /* month */ parseInt(extractedDateInfo[1]) - 1, + /* day */ parseInt(extractedDateInfo[2]), + /* hour */ parseInt(extractedDateInfo[3]) - (this.timezone / 60), + /* minute */ parseInt(extractedDateInfo[4]), + /* second */ parseInt(extractedDateInfo[5]), + /* millisecond */ parseInt(extractedDateInfo[6]) + ); + + const timezone = formatTimezoneOffset(this.timezone); + const year = correctedDate.getFullYear().toString().padStart(2, "0"); + // `Date.prototype.getMonth` returns the month starting at 0. + const month = (correctedDate.getMonth() + 1).toString().padStart(2, "0"); + const day = correctedDate.getDate().toString().padStart(2, "0"); + if (this.timeUnit === TimeUnit.Day) { + return `${year}-${month}-${day}${timezone}`; + } + + const hours = correctedDate.getHours().toString().padStart(2, "0"); + if (this.timeUnit === TimeUnit.Hour) { + return `${year}-${month}-${day}T${hours}${timezone}`; + } + + const minutes = correctedDate.getMinutes().toString().padStart(2, "0"); + if (this.timeUnit === TimeUnit.Minute) { + return `${year}-${month}-${day}T${hours}:${minutes}${timezone}`; + } + + const seconds = correctedDate.getSeconds().toString().padStart(2, "0"); + if (this.timeUnit === TimeUnit.Second) { + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${timezone}`; + } + + const milliseconds = correctedDate.getMilliseconds().toString().padStart(3, "0"); + if (this.timeUnit === TimeUnit.Millisecond) { + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}${timezone}`; + } + + if (this.timeUnit === TimeUnit.Microsecond) { + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}000${timezone}`; + } + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}000000${timezone}`; + } +} /** * A datetime metric. diff --git a/glean/src/core/metrics/types/datetime_metric.ts b/glean/src/core/metrics/types/datetime_metric.ts deleted file mode 100644 index 22b74f8f0..000000000 --- a/glean/src/core/metrics/types/datetime_metric.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* 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 { Metric } from "../metric.js"; -import TimeUnit from "../time_unit.js"; -import { isNumber, isObject, isString } from "../../utils.js"; - -/** - * Builds the formatted timezone offset string frim a given timezone. - * - * The format of the resulting string is `+02:00`. - * - * @param timezone A number representing the timezone offset to format, - * this is expected to be in minutes. - * - * @returns The formatted timezone offset string. - */ -export function formatTimezoneOffset(timezone: number): string { - const offset = (timezone / 60) * -1; - const sign = offset > 0 ? "+" : "-"; - const hours = Math.abs(offset).toString().padStart(2, "0"); - return `${sign}${hours}:00`; -} - -export type DatetimeInternalRepresentation = { - // The time unit of the metric type at the time of recording. - timeUnit: TimeUnit, - // This timezone should be the exact output of `Date.getTimezoneOffset` - // and as such it should alwaye be in minutes. - timezone: number, - // This date string should be the exact output of `Date.toISOString` - // and as such it is always in UTC. - date: string, -}; - -export class DatetimeMetric extends Metric { - constructor(v: unknown) { - super(v); - } - - static fromDate(v: Date, timeUnit: TimeUnit): DatetimeMetric { - return new DatetimeMetric({ - timeUnit, - timezone: v.getTimezoneOffset(), - date: v.toISOString() - }); - } - - /** - * Gets the datetime data as a Date object. - * - * # Note - * - * The object created here will be relative to local time. - * If the timezone at the time of recording is different, - * the timezone offset will be applied before transforming to an object. - * - * @returns A date object. - */ - get date(): Date { - return new Date(this._inner.date); - } - - private get timezone(): number { - return this._inner.timezone; - } - - private get timeUnit(): TimeUnit { - return this._inner.timeUnit; - } - - private get dateISOString(): string { - return this._inner.date; - } - - validate(v: unknown): v is DatetimeInternalRepresentation { - if (!isObject(v) || Object.keys(v).length !== 3) { - return false; - } - - const timeUnitVerification = "timeUnit" in v && isString(v.timeUnit) && Object.values(TimeUnit).includes(v.timeUnit as TimeUnit); - const timezoneVerification = "timezone" in v && isNumber(v.timezone); - const dateVerification = "date" in v && isString(v.date) && v.date.length === 24 && !isNaN(Date.parse(v.date)); - if (!timeUnitVerification || !timezoneVerification || !dateVerification) { - return false; - } - - return true; - } - - /** - * Gets this metrics value in its payload representation. - * - * For this metric, the payload is the timezone aware ISO date string truncated to the time unit - * given at the time of recording. - * - * # Note - * - * The timezone of the final string is the timezone at the time of recording. - * - * @returns The metric value. - */ - payload(): string { - const extractedDateInfo = this.dateISOString.match(/\d+/g); - if (!extractedDateInfo || extractedDateInfo.length < 0) { - // This is impossible because the date is always validated before setting - throw new Error("IMPOSSIBLE: Unable to extract date information from DatetimeMetric."); - } - const correctedDate = new Date( - /* year */ parseInt(extractedDateInfo[0]), - /* month */ parseInt(extractedDateInfo[1]) - 1, - /* day */ parseInt(extractedDateInfo[2]), - /* hour */ parseInt(extractedDateInfo[3]) - (this.timezone / 60), - /* minute */ parseInt(extractedDateInfo[4]), - /* second */ parseInt(extractedDateInfo[5]), - /* millisecond */ parseInt(extractedDateInfo[6]) - ); - - const timezone = formatTimezoneOffset(this.timezone); - const year = correctedDate.getFullYear().toString().padStart(2, "0"); - // `Date.prototype.getMonth` returns the month starting at 0. - const month = (correctedDate.getMonth() + 1).toString().padStart(2, "0"); - const day = correctedDate.getDate().toString().padStart(2, "0"); - if (this.timeUnit === TimeUnit.Day) { - return `${year}-${month}-${day}${timezone}`; - } - - const hours = correctedDate.getHours().toString().padStart(2, "0"); - if (this.timeUnit === TimeUnit.Hour) { - return `${year}-${month}-${day}T${hours}${timezone}`; - } - - const minutes = correctedDate.getMinutes().toString().padStart(2, "0"); - if (this.timeUnit === TimeUnit.Minute) { - return `${year}-${month}-${day}T${hours}:${minutes}${timezone}`; - } - - const seconds = correctedDate.getSeconds().toString().padStart(2, "0"); - if (this.timeUnit === TimeUnit.Second) { - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${timezone}`; - } - - const milliseconds = correctedDate.getMilliseconds().toString().padStart(3, "0"); - if (this.timeUnit === TimeUnit.Millisecond) { - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}${timezone}`; - } - - if (this.timeUnit === TimeUnit.Microsecond) { - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}000${timezone}`; - } - - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}000000${timezone}`; - } -} diff --git a/glean/src/core/metrics/types/event.ts b/glean/src/core/metrics/types/event.ts index f968b8dfc..51e3e185e 100644 --- a/glean/src/core/metrics/types/event.ts +++ b/glean/src/core/metrics/types/event.ts @@ -6,7 +6,7 @@ import type { CommonMetricData } from "../index.js"; import { MetricType } from "../index.js"; import type { ExtraMap} from "../events_database.js"; import { RecordedEvent } from "../events_database.js"; -import { isUndefined } from "../../utils.js"; +import { getMonotonicNow } from "../../utils.js"; import { Context } from "../../context.js"; const MAX_LENGTH_EXTRA_KEY_VALUE = 100; @@ -22,21 +22,6 @@ class EventMetricType extends MetricType { this.allowedExtraKeys = allowedExtraKeys; } - /** - * An helper function to aid mocking the time in tests. - * - * This is only meant to be overridden in tests. - * - * @returns the number of milliseconds since the time origin. - */ - protected getMonotonicNow(): number { - // Sadly, `performance.now` is not available outside of browsers, which - // means we should get creative to find a proper clock. Fall back to `Date.now` - // for now, until bug 1690528 is fixed. - const now = isUndefined(performance) ? Date.now() : performance.now(); - return Math.round(now / 1000); - } - /** * Record an event by using the information * provided by the instance of this class. @@ -52,7 +37,7 @@ class EventMetricType extends MetricType { return; } - const timestamp = this.getMonotonicNow(); + const timestamp = getMonotonicNow(); // Truncate the extra keys, if needed. let truncatedExtra: ExtraMap | undefined = undefined; diff --git a/glean/src/core/metrics/types/labeled.ts b/glean/src/core/metrics/types/labeled.ts index e6110df98..c6abfa58c 100644 --- a/glean/src/core/metrics/types/labeled.ts +++ b/glean/src/core/metrics/types/labeled.ts @@ -2,11 +2,131 @@ * 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 { CommonMetricData } from "../index.js"; +import type { CommonMetricData, MetricType } from "../index.js"; import type CounterMetricType from "./counter.js"; import type BooleanMetricType from "./boolean.js"; import type StringMetricType from "./string.js"; -import { combineIdentifierAndLabel, OTHER_LABEL } from "./labeled_utils.js"; +import type MetricsDatabase from "../database"; +import type { JSONValue } from "../../utils.js"; +import { Metric } from "../metric.js"; + +/** + * This is an internal metric representation for labeled metrics. + * + * This can be used to instruct the validators to simply report + * whatever is stored internally, without performing any specific + * validation. + */ +export class LabeledMetric extends Metric { + constructor(v: unknown) { + super(v); + } + + validate(v: unknown): v is JSONValue { + return true; + } + + payload(): JSONValue { + return this._inner; + } +} + +const MAX_LABELS = 16; +const MAX_LABEL_LENGTH = 61; +export const OTHER_LABEL = "__other__"; + +// ** IMPORTANT ** +// When changing this documentation or the regex, be sure to change the same code +// in the Glean SDK repository as well. +// +// This regex is used for matching against labels and should allow for dots, +// underscores, and/or hyphens. Labels are also limited to starting with either +// a letter or an underscore character. +// +// Some examples of good and bad labels: +// +// Good: +// * `this.is.fine` +// * `this_is_fine_too` +// * `this.is_still_fine` +// * `thisisfine` +// * `_.is_fine` +// * `this.is-fine` +// * `this-is-fine` +// Bad: +// * `this.is.not_fine_due_tu_the_length_being_too_long_i_thing.i.guess` +// * `1.not_fine` +// * `this.$isnotfine` +// * `-.not_fine` +const LABEL_REGEX = /^[a-z_][a-z0-9_-]{0,29}(\.[a-z_][a-z0-9_-]{0,29})*$/; + +/** + * Combines a metric's base identifier and label. + * + * @param metricName the metric base identifier + * @param label the label + * + * @returns a string representing the complete metric id including the label. + */ +export function combineIdentifierAndLabel( + metricName: string, + label: string +): string { + return `${metricName}/${label}`; +} + +/** + * Checks if the dynamic label stored in the metric data is + * valid. If not, record an error and store data in the "__other__" + * label. + * + * @param metricsDatabase the metrics database. + * @param metric the metric metadata. + * + * @returns a valid label that can be used to store data. + */ +export async function getValidDynamicLabel(metricsDatabase: MetricsDatabase, metric: MetricType): Promise { + // Note that we assume `metric.dynamicLabel` to always be available within this function. + // This is a safe assumptions because we should only call `getValidDynamicLabel` if we have + // a dynamic label. + if (metric.dynamicLabel === undefined) { + throw new Error("This point should never be reached."); + } + + const key = combineIdentifierAndLabel(metric.baseIdentifier(), metric.dynamicLabel); + + for (const ping of metric.sendInPings) { + if (await metricsDatabase.hasMetric(metric.lifetime, ping, metric.type, key)) { + return key; + } + } + + let numUsedKeys = 0; + for (const ping of metric.sendInPings) { + numUsedKeys += await metricsDatabase.countByBaseIdentifier( + metric.lifetime, + ping, + metric.type, + metric.baseIdentifier()); + } + + let hitError = false; + if (numUsedKeys >= MAX_LABELS) { + hitError = true; + } else if (metric.dynamicLabel.length > MAX_LABEL_LENGTH) { + console.error(`label length ${metric.dynamicLabel.length} exceeds maximum of ${MAX_LABEL_LENGTH}`); + hitError = true; + // TODO: record error in bug 1682574 + } else if (!LABEL_REGEX.test(metric.dynamicLabel)) { + console.error(`label must be snake_case, got '${metric.dynamicLabel}'`); + hitError = true; + // TODO: record error in bug 1682574 + } + + return (hitError) + ? combineIdentifierAndLabel(metric.baseIdentifier(), OTHER_LABEL) + : key; +} type SupportedLabeledTypes = CounterMetricType | BooleanMetricType | StringMetricType; diff --git a/glean/src/core/metrics/types/labeled_utils.ts b/glean/src/core/metrics/types/labeled_utils.ts deleted file mode 100644 index f84e6ca87..000000000 --- a/glean/src/core/metrics/types/labeled_utils.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* 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 { MetricType } from ".."; -import type MetricsDatabase from "../database"; - -const MAX_LABELS = 16; -const MAX_LABEL_LENGTH = 61; -export const OTHER_LABEL = "__other__"; - -// ** IMPORTANT ** -// When changing this documentation or the regex, be sure to change the same code -// in the Glean SDK repository as well. -// -// This regex is used for matching against labels and should allow for dots, -// underscores, and/or hyphens. Labels are also limited to starting with either -// a letter or an underscore character. -// -// Some examples of good and bad labels: -// -// Good: -// * `this.is.fine` -// * `this_is_fine_too` -// * `this.is_still_fine` -// * `thisisfine` -// * `_.is_fine` -// * `this.is-fine` -// * `this-is-fine` -// Bad: -// * `this.is.not_fine_due_tu_the_length_being_too_long_i_thing.i.guess` -// * `1.not_fine` -// * `this.$isnotfine` -// * `-.not_fine` -const LABEL_REGEX = /^[a-z_][a-z0-9_-]{0,29}(\.[a-z_][a-z0-9_-]{0,29})*$/; - -/** - * Combines a metric's base identifier and label. - * - * @param metricName the metric base identifier - * @param label the label - * - * @returns a string representing the complete metric id including the label. - */ -export function combineIdentifierAndLabel( - metricName: string, - label: string -): string { - return `${metricName}/${label}`; -} - -/** - * Checks if the dynamic label stored in the metric data is - * valid. If not, record an error and store data in the "__other__" - * label. - * - * @param metricsDatabase the metrics database. - * @param metric the metric metadata. - * - * @returns a valid label that can be used to store data. - */ -export async function getValidDynamicLabel(metricsDatabase: MetricsDatabase, metric: MetricType): Promise { - // Note that we assume `metric.dynamicLabel` to always be available within this function. - // This is a safe assumptions because we should only call `getValidDynamicLabel` if we have - // a dynamic label. - if (metric.dynamicLabel === undefined) { - throw new Error("This point should never be reached."); - } - - const key = combineIdentifierAndLabel(metric.baseIdentifier(), metric.dynamicLabel); - - for (const ping of metric.sendInPings) { - if (await metricsDatabase.hasMetric(metric.lifetime, ping, metric.type, key)) { - return key; - } - } - - let numUsedKeys = 0; - for (const ping of metric.sendInPings) { - numUsedKeys += await metricsDatabase.countByBaseIdentifier( - metric.lifetime, - ping, - metric.type, - metric.baseIdentifier()); - } - - let hitError = false; - if (numUsedKeys >= MAX_LABELS) { - hitError = true; - } else if (metric.dynamicLabel.length > MAX_LABEL_LENGTH) { - console.error(`label length ${metric.dynamicLabel.length} exceeds maximum of ${MAX_LABEL_LENGTH}`); - hitError = true; - // TODO: record error in bug 1682574 - } else if (!LABEL_REGEX.test(metric.dynamicLabel)) { - console.error(`label must be snake_case, got '${metric.dynamicLabel}'`); - hitError = true; - // TODO: record error in bug 1682574 - } - - return (hitError) - ? combineIdentifierAndLabel(metric.baseIdentifier(), OTHER_LABEL) - : key; -} diff --git a/glean/src/core/metrics/types/string.ts b/glean/src/core/metrics/types/string.ts index 764e6530a..9210c3e07 100644 --- a/glean/src/core/metrics/types/string.ts +++ b/glean/src/core/metrics/types/string.ts @@ -4,8 +4,33 @@ import type { CommonMetricData } from "../index.js"; import { MetricType } from "../index.js"; -import { MAX_LENGTH_VALUE, StringMetric } from "./string_metric.js"; import { Context } from "../../context.js"; +import { Metric } from "../metric.js"; +import { isString } from "../../utils.js"; + +export const MAX_LENGTH_VALUE = 100; + +export class StringMetric extends Metric { + constructor(v: unknown) { + super(v); + } + + validate(v: unknown): v is string { + if (!isString(v)) { + return false; + } + + if (v.length > MAX_LENGTH_VALUE) { + return false; + } + + return true; + } + + payload(): string { + return this._inner; + } +} /** * A string metric. diff --git a/glean/src/core/metrics/types/string_metric.ts b/glean/src/core/metrics/types/string_metric.ts deleted file mode 100644 index 2e42c1a2f..000000000 --- a/glean/src/core/metrics/types/string_metric.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* 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 { Metric } from "../metric.js"; -import { isString } from "../../utils.js"; - -export const MAX_LENGTH_VALUE = 100; - -export class StringMetric extends Metric { - constructor(v: unknown) { - super(v); - } - - validate(v: unknown): v is string { - if (!isString(v)) { - return false; - } - - if (v.length > MAX_LENGTH_VALUE) { - return false; - } - - return true; - } - - payload(): string { - return this._inner; - } -} diff --git a/glean/src/core/metrics/types/timespan.ts b/glean/src/core/metrics/types/timespan.ts new file mode 100644 index 000000000..60728b5b9 --- /dev/null +++ b/glean/src/core/metrics/types/timespan.ts @@ -0,0 +1,203 @@ +/* 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 { CommonMetricData} from "../index.js"; +import type { JSONValue } from "../../utils.js"; +import TimeUnit from "../time_unit.js"; +import { MetricType } from "../index.js"; +import { isString, isObject, isNumber, isUndefined, getMonotonicNow } from "../../utils.js"; +import { Metric } from "../metric.js"; +import { Context } from "../../context.js"; + +export type TimespanInternalRepresentation = { + // The time unit of the metric type at the time of recording. + timeUnit: TimeUnit, + // The timespan in milliseconds. + timespan: number, +}; +export class TimespanMetric extends Metric { + constructor(v: unknown) { + super(v); + } + + validate(v: unknown): v is TimespanInternalRepresentation { + if (!isObject(v) || Object.keys(v).length !== 2) { + return false; + } + + const timeUnitVerification = "timeUnit" in v && isString(v.timeUnit) && Object.values(TimeUnit).includes(v.timeUnit as TimeUnit); + const timespanVerification = "timespan" in v && isNumber(v.timespan) && v.timespan >= 0; + if (!timeUnitVerification || !timespanVerification) { + return false; + } + + return true; + } + + payload(): number { + switch(this._inner.timeUnit) { + case TimeUnit.Nanosecond: + return this._inner.timespan * 10**6; + case TimeUnit.Microsecond: + return this._inner.timespan * 10**3; + case TimeUnit.Millisecond: + return this._inner.timespan; + case TimeUnit.Second: + return Math.round(this._inner.timespan / 1000); + case TimeUnit.Minute: + return Math.round(this._inner.timespan / 1000 / 60); + case TimeUnit.Hour: + return Math.round(this._inner.timespan / 1000 / 60 / 60); + case TimeUnit.Day: + return Math.round(this._inner.timespan / 1000 / 60 / 60 / 24); + } + } +} + +/** + * A timespan metric. + * + * Timespans are used to make a measurement of how much time is spent in a particular task. + */ +class TimespanMetricType extends MetricType { + private timeUnit: TimeUnit; + startTime?: number; + + constructor(meta: CommonMetricData, timeUnit: string) { + super("timespan", meta); + this.timeUnit = timeUnit as TimeUnit; + } + + /** + * Starts tracking time for the provided metric. + * + * This records an error if it's already tracking time (i.e. start was + * already called with no corresponding `stop()`. In which case the original + * start time will be preserved. + */ + start(): void { + // Get the start time outside of the dispatched task so that + // it is the time this function is called and not the time the task is executed. + const startTime = getMonotonicNow(); + + Context.dispatcher.launch(async () => { + if (!this.shouldRecord(Context.uploadEnabled)) { + return; + } + + if (!isUndefined(this.startTime)) { + // TODO: record error once Bug 1682574 is resolved. + console.error("Timespan already started."); + return; + } + + this.startTime = startTime; + + return Promise.resolve(); + }); + } + + /** + * Stops tracking time for the provided metric. Sets the metric to the elapsed time. + * + * This will record an error if no `start()` was called. + */ + stop(): void { + // Get the stop time outside of the dispatched task so that + // it is the time this function is called and not the time the task is executed. + const stopTime = getMonotonicNow(); + + Context.dispatcher.launch(async () => { + if (!this.shouldRecord(Context.uploadEnabled)) { + // Reset timer when disabled, so that we don't record timespans across + // disabled/enabled toggling. + this.startTime = undefined; + return; + } + + if (isUndefined(this.startTime)) { + // TODO: record error once Bug 1682574 is resolved. + console.error("Timespan not running."); + return; + } + + const elapsed = stopTime - this.startTime; + this.startTime = undefined; + + if (elapsed < 0) { + // TODO: record error once Bug 1682574 is resolved. + console.error("Timespan was negative."); + return; + } + + let reportValueExists = false; + const transformFn = ((elapsed) => { + return (old?: JSONValue): TimespanMetric => { + let metric: TimespanMetric; + try { + metric = new TimespanMetric(old); + // If creating the metric didn't error, + // there is a valid timespan already recorded for this metric. + reportValueExists = true; + } catch { + metric = new TimespanMetric({ + timespan: elapsed, + timeUnit: this.timeUnit, + }); + } + + return metric; + }; + })(elapsed); + + await Context.metricsDatabase.transform(this, transformFn); + + if (reportValueExists) { + // TODO: record error once Bug 1682574 is resolved. + console.error("Timespan value already recorded. New value discarded."); + } + }); + } + + /** + * Aborts a previous `start()` call. + * + * No error is recorded if no `start()` was called. + */ + cancel(): void { + Context.dispatcher.launch(() => { + this.startTime = undefined; + return Promise.resolve(); + }); + } + + /** + * **Test-only API.** + * + * Gets the currently stored value as a number. + * + * This doesn't clear the stored value. + * + * TODO: Only allow this function to be called on test mode (depends on Bug 1682771). + * + * @param ping the ping from which we want to retrieve this metrics value from. + * Defaults to the first value in `sendInPings`. + * + * @returns The value found in storage or `undefined` if nothing was found. + */ + async testGetValue(ping: string = this.sendInPings[0]): Promise { + let value: TimespanInternalRepresentation | undefined; + await Context.dispatcher.testLaunch(async () => { + value = await Context.metricsDatabase.getMetric(ping, this); + }); + + if (value) { + // `payload` will truncate to the defined time_unit at the time of recording. + return (new TimespanMetric(value)).payload(); + } + } +} + +export default TimespanMetricType; + diff --git a/glean/src/core/metrics/types/uuid.ts b/glean/src/core/metrics/types/uuid.ts index 551bf473f..4647bd0b9 100644 --- a/glean/src/core/metrics/types/uuid.ts +++ b/glean/src/core/metrics/types/uuid.ts @@ -4,9 +4,33 @@ import type { CommonMetricData } from "../index.js"; import { MetricType } from "../index.js"; -import { generateUUIDv4 } from "../../utils.js"; -import { UUIDMetric } from "./uuid_metric.js"; +import { generateUUIDv4, isString } from "../../utils.js"; import { Context } from "../../context.js"; +import { validate as UUIDvalidate } from "uuid"; +import { KNOWN_CLIENT_ID } from "../../constants.js"; +import { Metric } from "../metric.js"; + +export class UUIDMetric extends Metric { + constructor(v: unknown) { + super(v); + } + + validate(v: unknown): v is string { + if (!isString(v)) { + return false; + } + + if (v === KNOWN_CLIENT_ID) { + return true; + } + + return UUIDvalidate(v); + } + + payload(): string { + return this._inner; + } +} /** * An UUID metric. diff --git a/glean/src/core/metrics/types/uuid_metric.ts b/glean/src/core/metrics/types/uuid_metric.ts deleted file mode 100644 index 3ed116545..000000000 --- a/glean/src/core/metrics/types/uuid_metric.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* 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 { validate as UUIDvalidate } from "uuid"; -import { KNOWN_CLIENT_ID } from "../../constants.js"; -import { Metric } from "../metric.js"; -import { isString } from "../../utils.js"; - -export class UUIDMetric extends Metric { - constructor(v: unknown) { - super(v); - } - - validate(v: unknown): v is string { - if (!isString(v)) { - return false; - } - - if (v === KNOWN_CLIENT_ID) { - return true; - } - - return UUIDvalidate(v); - } - - payload(): string { - return this._inner; - } -} diff --git a/glean/src/core/metrics/utils.ts b/glean/src/core/metrics/utils.ts index d66af5008..94e013fd6 100644 --- a/glean/src/core/metrics/utils.ts +++ b/glean/src/core/metrics/utils.ts @@ -2,15 +2,17 @@ * 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 { LabeledMetric } from "./index.js"; + import type { Metric } from "./metric.js"; import type { JSONValue } from "../utils.js"; -import { BooleanMetric } from "./types/boolean_metric.js"; -import { CounterMetric } from "./types/counter_metric.js"; -import { DatetimeMetric } from "./types/datetime_metric.js"; -import { StringMetric } from "./types/string_metric.js"; -import { UUIDMetric } from "./types/uuid_metric.js"; +import { LabeledMetric } from "./types/labeled.js"; +import { BooleanMetric } from "./types/boolean.js"; +import { CounterMetric } from "./types/counter.js"; +import { DatetimeMetric } from "./types/datetime.js"; +import { StringMetric } from "./types/string.js"; +import { TimespanMetric } from "./types/timespan.js"; +import { UUIDMetric } from "./types/uuid.js"; /** * A map containing all supported internal metrics and its constructors. @@ -25,6 +27,7 @@ const METRIC_MAP: { "labeled_counter": LabeledMetric, "labeled_string": LabeledMetric, "string": StringMetric, + "timespan": TimespanMetric, "uuid": UUIDMetric, }); diff --git a/glean/src/core/pings/maker.ts b/glean/src/core/pings/maker.ts index d3a206a03..a726fcc12 100644 --- a/glean/src/core/pings/maker.ts +++ b/glean/src/core/pings/maker.ts @@ -3,17 +3,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { GLEAN_SCHEMA_VERSION, GLEAN_VERSION, PING_INFO_STORAGE, CLIENT_INFO_STORAGE } from "../constants.js"; -import CounterMetricType from "../metrics/types/counter.js"; -import { CounterMetric } from "../metrics/types/counter_metric.js"; -import DatetimeMetricType from "../metrics/types/datetime.js"; -import { DatetimeMetric } from "../metrics/types/datetime_metric.js"; -import TimeUnit from "../metrics/time_unit.js"; import type { ClientInfo, PingInfo, PingPayload } from "../pings/ping_payload.js"; import type CommonPingData from "./common_ping_data.js"; -import CoreEvents from "../events/index.js"; import type MetricsDatabase from "../metrics/database.js"; import type EventsDatabase from "../metrics/events_database.js"; import type { DebugOptions } from "../debug_options.js"; +import CounterMetricType from "../metrics/types/counter.js"; +import { CounterMetric } from "../metrics/types/counter.js"; +import DatetimeMetricType from "../metrics/types/datetime.js"; +import { DatetimeMetric } from "../metrics/types/datetime.js"; +import TimeUnit from "../metrics/time_unit.js"; +import CoreEvents from "../events/index.js"; import { Lifetime } from "../metrics/lifetime.js"; import { Context } from "../context.js"; diff --git a/glean/src/core/pings/ping_payload.ts b/glean/src/core/pings/ping_payload.ts index 6fb335025..54a8f3637 100644 --- a/glean/src/core/pings/ping_payload.ts +++ b/glean/src/core/pings/ping_payload.ts @@ -2,7 +2,7 @@ * 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 { Metrics } from "../metrics/metrics_interface.js"; +import type { Metrics } from "../metrics/index.js"; import type { JSONObject, JSONArray } from "../utils.js"; export interface PingInfo extends JSONObject { diff --git a/glean/src/core/utils.ts b/glean/src/core/utils.ts index 3faaa1f4e..68d83536e 100644 --- a/glean/src/core/utils.ts +++ b/glean/src/core/utils.ts @@ -159,3 +159,17 @@ export function generateUUIDv4(): string { }); } } + +/** + * A helper function to aid mocking the time in tests. + * + * This is only meant to be overridden in tests. + * + * @returns The number of milliseconds since the time origin. + */ +export function getMonotonicNow(): number { + // Sadly, `performance.now` is not available on Qt, which + // means we should get creative to find a proper clock for that platform. + // Fall back to `Date.now` for now, until bug 1690528 is fixed. + return typeof performance === "undefined" ? Date.now() : performance.now(); +} diff --git a/glean/tests/core/metrics/database.spec.ts b/glean/tests/core/metrics/database.spec.ts index 877429cb8..3421d0215 100644 --- a/glean/tests/core/metrics/database.spec.ts +++ b/glean/tests/core/metrics/database.spec.ts @@ -6,7 +6,7 @@ import assert from "assert"; import Database, { isValidInternalMetricsRepresentation } from "../../../src/core/metrics/database"; import StringMetricType from "../../../src/core/metrics/types/string"; -import { StringMetric } from "../../../src/core/metrics/types/string_metric"; +import { StringMetric } from "../../../src/core/metrics/types/string"; import type { JSONValue } from "../../../src/core/utils"; import Glean from "../../../src/core/glean"; import { Lifetime } from "../../../src/core/metrics/lifetime"; diff --git a/glean/tests/core/metrics/datetime.spec.ts b/glean/tests/core/metrics/datetime.spec.ts index a635307e8..f66e1e2e6 100644 --- a/glean/tests/core/metrics/datetime.spec.ts +++ b/glean/tests/core/metrics/datetime.spec.ts @@ -7,7 +7,7 @@ import sinon from "sinon"; import Glean from "../../../src/core/glean"; import DatetimeMetricType from "../../../src/core/metrics/types/datetime"; -import { DatetimeMetric } from "../../../src/core/metrics/types/datetime_metric"; +import { DatetimeMetric } from "../../../src/core/metrics/types/datetime"; import TimeUnit from "../../../src/core/metrics/time_unit"; import { Lifetime } from "../../../src/core/metrics/lifetime"; import { Context } from "../../../src/core/context"; diff --git a/glean/tests/core/metrics/event.spec.ts b/glean/tests/core/metrics/event.spec.ts index 69750f62b..5be83a8aa 100644 --- a/glean/tests/core/metrics/event.spec.ts +++ b/glean/tests/core/metrics/event.spec.ts @@ -2,21 +2,12 @@ * 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 { performance } from "perf_hooks"; import assert from "assert"; import Glean from "../../../src/core/glean"; import EventMetricType from "../../../src/core/metrics/types/event"; import { Lifetime } from "../../../src/core/metrics/lifetime"; -// A test event type that exclusively overrides the -// monotonic timer. -class TestEventMetricType extends EventMetricType { - getMonotonicNow(): number { - return Math.round(performance.now() / 1000); - } -} - describe("EventMetric", function() { const testAppId = `gleanjs.test.${this.title}`; @@ -25,7 +16,7 @@ describe("EventMetric", function() { }); it("the API records to its storage engine", async function () { - const click = new TestEventMetricType({ + const click = new EventMetricType({ category: "ui", name: "click", sendInPings: ["store1"], @@ -58,7 +49,7 @@ describe("EventMetric", function() { }); it("the API records when category is empty", async function () { - const click = new TestEventMetricType({ + const click = new EventMetricType({ category: "", name: "click", sendInPings: ["store1"], @@ -86,7 +77,7 @@ describe("EventMetric", function() { }); it("disabled events must not record data", async function () { - const click = new TestEventMetricType({ + const click = new EventMetricType({ category: "ui", name: "click", sendInPings: ["store1"], @@ -101,7 +92,7 @@ describe("EventMetric", function() { }); it("events should not record when upload is disabled", async function () { - const click = new TestEventMetricType({ + const click = new EventMetricType({ category: "ui", name: "click", sendInPings: ["store1"], @@ -133,7 +124,7 @@ describe("EventMetric", function() { it("records properly without optional arguments", async function () { const pings = ["store1", "store2"]; - const metric = new TestEventMetricType({ + const metric = new EventMetricType({ category: "telemetry", name: "test_event_no_optional", sendInPings: pings, @@ -156,7 +147,7 @@ describe("EventMetric", function() { it("records properly with optional arguments", async function () { const pings = ["store1", "store2"]; - const metric = new TestEventMetricType({ + const metric = new EventMetricType({ category: "telemetry", name: "test_event_with_optional", sendInPings: pings, @@ -186,7 +177,7 @@ describe("EventMetric", function() { it.skip("bug 1690253: send an event ping when it fills up"); it("extra keys must be recorded and truncated if needed", async function () { - const testEvent = new TestEventMetricType({ + const testEvent = new EventMetricType({ category: "ui", name: "testEvent", sendInPings: ["store1"], diff --git a/glean/tests/core/metrics/labeled.spec.ts b/glean/tests/core/metrics/labeled.spec.ts index cd58fced3..64d2b09d7 100644 --- a/glean/tests/core/metrics/labeled.spec.ts +++ b/glean/tests/core/metrics/labeled.spec.ts @@ -4,8 +4,8 @@ import assert from "assert"; import sinon from "sinon"; -import { Context } from "../../../src/core/context"; +import { Context } from "../../../src/core/context"; import Glean from "../../../src/core/glean"; import { Lifetime } from "../../../src/core/metrics/lifetime"; import BooleanMetricType from "../../../src/core/metrics/types/boolean"; diff --git a/glean/tests/core/metrics/string.spec.ts b/glean/tests/core/metrics/string.spec.ts index a509d3870..7f343f8a7 100644 --- a/glean/tests/core/metrics/string.spec.ts +++ b/glean/tests/core/metrics/string.spec.ts @@ -7,7 +7,7 @@ import assert from "assert"; import Glean from "../../../src/core/glean"; import StringMetricType from "../../../src/core/metrics/types/string"; import { Lifetime } from "../../../src/core/metrics/lifetime"; -import { MAX_LENGTH_VALUE } from "../../../src/core/metrics/types/string_metric"; +import { MAX_LENGTH_VALUE } from "../../../src/core/metrics/types/string"; import { Context } from "../../../src/core/context"; describe("StringMetric", function() { diff --git a/glean/tests/core/metrics/timespan.spec.ts b/glean/tests/core/metrics/timespan.spec.ts new file mode 100644 index 000000000..838bc8e19 --- /dev/null +++ b/glean/tests/core/metrics/timespan.spec.ts @@ -0,0 +1,302 @@ +/* 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 assert from "assert"; +import sinon from "sinon"; + +import { Context } from "../../../src/core/context"; +import Glean from "../../../src/core/glean"; +import { Lifetime } from "../../../src/core/metrics/lifetime"; +import TimeUnit from "../../../src/core/metrics/time_unit"; +import TimespanMetricType, { TimespanMetric } from "../../../src/core/metrics/types/timespan"; + +const sandbox = sinon.createSandbox(); + +describe("TimespanMetric", function() { + const testAppId = `gleanjs.test.${this.title}`; + + beforeEach(async function() { + await Glean.testResetGlean(testAppId); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it("timespan internal representation validation works as expected", function () { + // Invalid objects + assert.throws(() => new TimespanMetric(undefined)); + assert.throws(() => new TimespanMetric(null)); + assert.throws(() => new TimespanMetric({})); + assert.throws(() => new TimespanMetric({ rubbish: "garbage" })); + assert.throws(() => new TimespanMetric({ rubbish: "garbage", timeUnit: "milliseconds" })); + + // Invalid time units + assert.throws(() => new TimespanMetric({ timeUnit: "garbage", timespan: 10 })); + assert.throws(() => new TimespanMetric({ timeUnit: null, timespan: 10 })); + assert.throws(() => new TimespanMetric({ timeUnit: "hour" })); + + // Invalid timespans + assert.throws(() => new TimespanMetric({ timeUnit: "hour", timespan: -300 })); + assert.throws(() => new TimespanMetric({ timeUnit: "hour", timespan: "aaaaaaaaaaaaaaaaaaaaaaaa" })); + assert.throws(() => new TimespanMetric({ timespan: 10 })); + + // Valid values + assert.doesNotThrow(() => new TimespanMetric({ timeUnit: "millisecond", timespan: 300 })); + }); + + it("attempting to get the value of a metric that hasn't been recorded doesn't error", async function() { + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing", "twoPing", "threePing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + assert.strictEqual(await metric.testGetValue("aPing"), undefined); + }); + + it("attempting to start/stop when glean upload is disabled is a no-op", async function() { + Glean.setUploadEnabled(false); + + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing", "twoPing", "threePing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + metric.start(); + metric.stop(); + + assert.strictEqual(await metric.testGetValue("aPing"), undefined); + }); + + it("ping payload is correct", async function() { + const fakeDateNow = sandbox.stub(Date, "now"); + fakeDateNow.onCall(0).callsFake(() => 0); + fakeDateNow.onCall(1).callsFake(() => 100); + + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + metric.start(); + metric.stop(); + assert.strictEqual(await metric.testGetValue("aPing"), 100); + + const snapshot = await Context.metricsDatabase.getPingMetrics("aPing", true); + assert.deepStrictEqual(snapshot, { + "timespan": { + "aCategory.aTimespan": 100 + } + }); + }); + + it("recording APIs properly sets the value in all pings", async function() { + const fakeDateNow = sandbox.stub(Date, "now"); + fakeDateNow.onCall(0).callsFake(() => 0); + fakeDateNow.onCall(1).callsFake(() => 100); + + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing", "twoPing", "threePing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + metric.start(); + metric.stop(); + assert.strictEqual(await metric.testGetValue("aPing"), 100); + assert.strictEqual(await metric.testGetValue("twoPing"), 100); + assert.strictEqual(await metric.testGetValue("threePing"), 100); + }); + + it("truncation works", async function() { + const testCases = [ + { + unit: TimeUnit.Nanosecond, + expected: 3600000000000, + }, + { + unit: TimeUnit.Microsecond, + expected: 3600000000, + }, + { + unit: TimeUnit.Millisecond, + expected: 3600000, + }, + { + unit: TimeUnit.Second, + expected: 3600, + }, + { + unit: TimeUnit.Minute, + expected: 60, + }, + { + unit: TimeUnit.Hour, + expected: 1, + }, + { + unit: TimeUnit.Day, + expected: 0, + }, + ]; + + for (const testCase of testCases) { + const fakeDateNow = sandbox.stub(Date, "now"); + fakeDateNow.onCall(0).callsFake(() => 0); + fakeDateNow.onCall(1).callsFake(() => 3600000); // One hour. + + const metric = new TimespanMetricType({ + category: "aCategory", + name: `aDatetimeMetric_${testCase.unit}`, + sendInPings: ["aPing"], + lifetime: Lifetime.Ping, + disabled: false + }, testCase.unit); + + metric.start(); + metric.stop(); + assert.strictEqual(await metric.testGetValue("aPing"), testCase.expected); + + sandbox.restore(); + } + }); + + it("second timer run is skipped", async function() { + const fakeDateNow = sandbox.stub(Date, "now"); + // First check, duration: 100 + fakeDateNow.onCall(0).callsFake(() => 0); + fakeDateNow.onCall(1).callsFake(() => 100); + // Second check, duration 99 + fakeDateNow.onCall(2).callsFake(() => 101); + fakeDateNow.onCall(3).callsFake(() => 200); + + // TODO: check number of recorded errors instead once Bug 1682574 is resolved. + const consoleErrorSpy = sandbox.spy(console, "error"); + + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + metric.start(); + metric.stop(); + assert.strictEqual(await metric.testGetValue("aPing"), 100); + + // No error should be logged here: we had no prior value stored. + assert.deepStrictEqual(consoleErrorSpy.callCount, 0); + + metric.start(); + metric.stop(); + // First value should not be overwritten + assert.strictEqual(await metric.testGetValue("aPing"), 100); + + // Make sure that the error has been logged: we had a stored value, + // the new measurement was dropped. + assert.deepStrictEqual(consoleErrorSpy.callCount, 1); + }); + + it("cancel does not store and clears start time", async function() { + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + metric.start(); + metric.cancel(); + assert.strictEqual(await metric.testGetValue("aPing"), undefined); + assert.strictEqual(metric["startTime"], undefined); + }); + + it("nothing is stored before stop", async function() { + const fakeDateNow = sandbox.stub(Date, "now"); + fakeDateNow.onCall(0).callsFake(() => 0); + fakeDateNow.onCall(1).callsFake(() => 100); + + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + metric.start(); + assert.strictEqual(await metric.testGetValue("aPing"), undefined); + + metric.stop(); + assert.strictEqual(await metric.testGetValue("aPing"), 100); + }); + + it("timespan is not tracked across upload toggle", async function() { + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + // Timer is started. + metric.start(); + // User disables telemetry upload. + Glean.setUploadEnabled(false); + // App code eventually stops the timer. + // We should clear internal state as upload is disabled. + metric.stop(); + + // App code eventually starts the timer again. + // Upload is disabled, so this should not have any effect. + metric.start(); + // User enables telemetry upload again. + Glean.setUploadEnabled(true); + // App code eventually stops the timer. + // None should be running. + metric.stop(); + + // Nothing should have been recorded. + assert.strictEqual(await metric.testGetValue("aPing"), undefined); + + // TODO: Make sure also incalid state error was recorded. + }); + + it("time cannot go backwards", async function() { + const fakeDateNow = sandbox.stub(Date, "now"); + fakeDateNow.onCall(0).callsFake(() => 100); + fakeDateNow.onCall(1).callsFake(() => 0); + + const metric = new TimespanMetricType({ + category: "aCategory", + name: "aTimespan", + sendInPings: ["aPing"], + lifetime: Lifetime.Ping, + disabled: false + }, "millisecond"); + + // TODO: check number of recorded errors instead once Bug 1682574 is resolved. + const consoleErrorSpy = sandbox.spy(console, "error"); + + metric.start(); + metric.stop(); + assert.strictEqual(await metric.testGetValue("aPing"), undefined); + + assert.deepStrictEqual(consoleErrorSpy.callCount, 1); + }); +});