Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* [#101](https://github.com/mozilla/glean.js/pull/101): BUGFIX: Only validate Debug View Tag and Source Tags when they are present.
* [#101](https://github.com/mozilla/glean.js/pull/101): BUGFIX: Only validate Debug View Tag and Source Tags when they are present.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have two entries here... Whathever, can be fixed when we make the next release.

* [#102](https://github.com/mozilla/glean.js/pull/102): BUGFIX: Include a Glean User-Agent header in all pings.
* [#97](https://github.com/mozilla/glean.js/pull/97): Add support for labeled metric types (string, boolean and counter).

# v0.4.0 (2021-03-10)

Expand Down
89 changes: 79 additions & 10 deletions glean/src/core/metrics/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import Store from "../storage";
import { MetricType, Lifetime, Metric } from "./";
import { createMetric, validateMetricInternalRepresentation } from "./utils";
import { isObject, isUndefined, JSONValue } from "../utils";
import { isObject, isUndefined, JSONObject, JSONValue } from "../utils";
import Glean from "../glean";

export interface Metrics {
Expand Down Expand Up @@ -133,13 +133,50 @@ class MetricsDatabase {
}

const store = this._chooseStore(metric.lifetime);
const storageKey = metric.identifier;
const storageKey = await metric.identifier();
for (const ping of metric.sendInPings) {
const finalTransformFn = (v?: JSONValue): JSONValue => transformFn(v).get();
await store.update([ping, metric.type, storageKey], finalTransformFn);
}
}

/**
* Checks if anything was stored for the provided metric.
*
* @param lifetime the metric `Lifetime`.
* @param ping the ping storage to search in.
* @param metricType the type of the metric.
* @param metricIdentifier the metric identifier.
*
* @returns `true` if the metric was found (regardless of the validity of the
* stored data), `false` otherwise.
*/
async hasMetric(lifetime: Lifetime, ping: string, metricType: string, metricIdentifier: string): Promise<boolean> {
const store = this._chooseStore(lifetime);
const value = await store.get([ping, metricType, metricIdentifier]);
return !isUndefined(value);
}

/**
* Counts the number of stored metrics with an id starting with a specific identifier.
*
* @param lifetime the metric `Lifetime`.
* @param ping the ping storage to search in.
* @param metricType the type of the metric.
* @param metricIdentifier the metric identifier.
*
* @returns the number of stored metrics with their id starting with the given identifier.
*/
async countByBaseIdentifier(lifetime: Lifetime, ping: string, metricType: string, metricIdentifier: string): Promise<number> {
const store = this._chooseStore(lifetime);
const pingStorage = await store.get([ping, metricType]);
if (isUndefined(pingStorage)) {
return 0;
}

return Object.keys(pingStorage).filter(n => n.startsWith(metricIdentifier)).length;
}

/**
* Gets and validates the persisted payload of a given metric in a given ping.
*
Expand Down Expand Up @@ -168,10 +205,10 @@ class MetricsDatabase {
metric: MetricType
): Promise<T | undefined> {
const store = this._chooseStore(metric.lifetime);
const storageKey = metric.identifier;
const storageKey = await metric.identifier();
const value = await store.get([ping, metric.type, storageKey]);
if (!isUndefined(value) && !validateMetricInternalRepresentation<T>(metric.type, value)) {
console.error(`Unexpected value found for metric ${metric.identifier}: ${JSON.stringify(value)}. Clearing.`);
console.error(`Unexpected value found for metric ${storageKey}: ${JSON.stringify(value)}. Clearing.`);
await store.delete([ping, metric.type, storageKey]);
return;
} else {
Expand Down Expand Up @@ -210,6 +247,30 @@ class MetricsDatabase {
return data;
}

private processLabeledMetric(snapshot: Metrics, metricType: string, metricId: string, metricData: JSONValue) {
const newType = `labeled_${metricType}`;
const idLabelSplit = metricId.split("/", 2);
const newId = idLabelSplit[0];
const label = idLabelSplit[1];

if (newType in snapshot && newId in snapshot[newType]) {
// Other labels were found for this metric. Do not throw them away.
const existingData = snapshot[newType][newId];
snapshot[newType][newId] = {
...(existingData as JSONObject),
[label]: metricData
};
} else {
// This is the first label for this metric.
snapshot[newType] = {
...snapshot[newType],
[newId]: {
[label]: metricData
}
};
}
}

/**
* Gets all of the persisted metrics related to a given ping.
*
Expand All @@ -228,13 +289,21 @@ class MetricsDatabase {
await this.clear(Lifetime.Ping, ping);
}

const response: Metrics = { ...pingData };
for (const data of [userData, appData]) {
const response: Metrics = {};
for (const data of [userData, pingData, appData]) {
for (const metricType in data) {
response[metricType] = {
...response[metricType],
...data[metricType]
};
for (const metricId in data[metricType]) {
if (metricId.includes("/")) {
// While labeled data is stored within the subtype storage (e.g. counter storage), it
// needs to live in a different section of the ping payload (e.g. `labeled_counter`).
this.processLabeledMetric(response, metricType, metricId, data[metricType][metricId]);
} else {
response[metricType] = {
...response[metricType],
[metricId]: data[metricType][metricId]
};
}
}
}
}

Expand Down
63 changes: 58 additions & 5 deletions glean/src/core/metrics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
* 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 { JSONValue } from "../utils";
import { isUndefined, JSONValue } from "../utils";
import Glean from "../glean";
import LabeledMetricType from "./types/labeled";

/**
* The Metric class describes the shared behaviour amongst concrete metrics.
Expand Down Expand Up @@ -101,7 +102,14 @@ export interface CommonMetricData {
// Whether or not the metric is disabled.
//
// Disabled metrics are never recorded.
readonly disabled: boolean
readonly disabled: boolean,
// Dynamic label.
//
// When a labeled metric factory creates the specific metric to be recorded to,
// dynamic labels are stored in the metadata so that we can validate them when
// the Glean singleton is available (because metrics can be recorded before Glean
// is initialized).
dynamicLabel?: string
}

/**
Expand All @@ -114,6 +122,7 @@ export abstract class MetricType implements CommonMetricData {
readonly sendInPings: string[];
readonly lifetime: Lifetime;
readonly disabled: boolean;
dynamicLabel?: string;

constructor(type: string, meta: CommonMetricData) {
this.type = type;
Expand All @@ -123,21 +132,41 @@ export abstract class MetricType implements CommonMetricData {
this.sendInPings = meta.sendInPings;
this.lifetime = meta.lifetime as Lifetime;
this.disabled = meta.disabled;
this.dynamicLabel = meta.dynamicLabel;
}

/**
* This metric's unique identifier, including the category and name.
* The metric's base identifier, including the category and name, but not the label.
*
* @returns The generated identifier.
* @returns The generated identifier. If `category` is empty, it's ommitted. Otherwise,
* it's the combination of the metric's `category` and `name`.
*/
get identifier(): string {
baseIdentifier(): string {
if (this.category.length > 0) {
return `${this.category}.${this.name}`;
} else {
return this.name;
}
}

/**
* The metric's unique identifier, including the category, name and label.
*
* @returns The generated identifier. If `category` is empty, it's ommitted. Otherwise,
* it's the combination of the metric's `category`, `name` and `label`.
*/
async identifier(): Promise<string> {
const baseIdentifier = this.baseIdentifier();

// We need to use `isUndefined` and cannot use `(this.dynamicLabel)` because we want
// empty strings to propagate as dynamic labels, so that erros are potentially recorded.
if (!isUndefined(this.dynamicLabel)) {
return await LabeledMetricType.getValidDynamicLabel(this);
} else {
return baseIdentifier;
}
}

/**
* Verify whether or not this metric instance should be recorded.
*
Expand All @@ -147,3 +176,27 @@ export abstract class MetricType implements CommonMetricData {
return (Glean.isUploadEnabled() && !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<JSONValue, JSONValue> {
constructor(v: unknown) {
super(v);
}

validate(v: unknown): v is JSONValue {
return true;
}

payload(): JSONValue {
return this._inner;
}
}
Loading