Skip to content

Commit 9c9a8bd

Browse files
committed
Enable the collection of labeled types
This internally changes the id getter on the `MetricType` to read from the store in case of dyanmic labels, as done in the Glean SDK. This is required in order to attempt recording to Glean metrics before it gets initialized.
1 parent 2c1481d commit 9c9a8bd

File tree

3 files changed

+136
-16
lines changed

3 files changed

+136
-16
lines changed

glean/src/core/metrics/database.ts

+79-10
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import Store from "../storage";
66
import { MetricType, Lifetime, Metric } from "./";
77
import { createMetric, validateMetricInternalRepresentation } from "./utils";
8-
import { isObject, isUndefined, JSONValue } from "../utils";
8+
import { isObject, isUndefined, JSONObject, JSONValue } from "../utils";
99
import Glean from "../glean";
1010

1111
export interface Metrics {
@@ -133,13 +133,50 @@ class MetricsDatabase {
133133
}
134134

135135
const store = this._chooseStore(metric.lifetime);
136-
const storageKey = metric.identifier;
136+
const storageKey = await metric.getAsyncIdentifier();
137137
for (const ping of metric.sendInPings) {
138138
const finalTransformFn = (v?: JSONValue): JSONValue => transformFn(v).get();
139139
await store.update([ping, metric.type, storageKey], finalTransformFn);
140140
}
141141
}
142142

143+
/**
144+
* Checks if anything was stored for the provided metric.
145+
*
146+
* @param lifetime the metric `Lifetime`.
147+
* @param ping the ping storage to search in.
148+
* @param metricType the type of the metric.
149+
* @param metricIdentifier the metric identifier.
150+
*
151+
* @returns `true` if the metric was found (regardless of the validity of the
152+
* stored data), `false` otherwise.
153+
*/
154+
async hasMetric(lifetime: Lifetime, ping: string, metricType: string, metricIdentifier: string): Promise<boolean> {
155+
const store = this._chooseStore(lifetime);
156+
const value = await store.get([ping, metricType, metricIdentifier]);
157+
return !isUndefined(value);
158+
}
159+
160+
/**
161+
* Counts the number of stored metrics with an id starting with a specific identifier.
162+
*
163+
* @param lifetime the metric `Lifetime`.
164+
* @param ping the ping storage to search in.
165+
* @param metricType the type of the metric.
166+
* @param metricIdentifier the metric identifier.
167+
*
168+
* @returns the number of stored metrics with their id starting with the given identifier.
169+
*/
170+
async countByBaseIdentifier(lifetime: Lifetime, ping: string, metricType: string, metricIdentifier: string): Promise<number> {
171+
const store = this._chooseStore(lifetime);
172+
const pingStorage = await store.get([ping, metricType]);
173+
if (isUndefined(pingStorage)) {
174+
return 0;
175+
}
176+
177+
return Object.keys(pingStorage).filter(n => n.startsWith(metricIdentifier)).length;
178+
}
179+
143180
/**
144181
* Gets and validates the persisted payload of a given metric in a given ping.
145182
*
@@ -168,10 +205,10 @@ class MetricsDatabase {
168205
metric: MetricType
169206
): Promise<T | undefined> {
170207
const store = this._chooseStore(metric.lifetime);
171-
const storageKey = metric.identifier;
208+
const storageKey = await metric.getAsyncIdentifier();
172209
const value = await store.get([ping, metric.type, storageKey]);
173210
if (!isUndefined(value) && !validateMetricInternalRepresentation<T>(metric.type, value)) {
174-
console.error(`Unexpected value found for metric ${metric.identifier}: ${JSON.stringify(value)}. Clearing.`);
211+
console.error(`Unexpected value found for metric ${storageKey}: ${JSON.stringify(value)}. Clearing.`);
175212
await store.delete([ping, metric.type, storageKey]);
176213
return;
177214
} else {
@@ -210,6 +247,30 @@ class MetricsDatabase {
210247
return data;
211248
}
212249

250+
private processLabeledMetric(snapshot: Metrics, metricType: string, metricId: string, metricData: JSONValue) {
251+
const newType = `labeled_${metricType}`;
252+
const idLabelSplit = metricId.split("/", 2);
253+
const newId = idLabelSplit[0];
254+
const label = idLabelSplit[1];
255+
256+
if (newType in snapshot && newId in snapshot[newType]) {
257+
// Other labels were found for this metric. Do not throw them away.
258+
const existingData = snapshot[newType][newId];
259+
snapshot[newType][newId] = {
260+
...(existingData as JSONObject),
261+
[label]: metricData
262+
};
263+
} else {
264+
// This is the first label for this metric.
265+
snapshot[newType] = {
266+
...snapshot[newType],
267+
[newId]: {
268+
[label]: metricData
269+
}
270+
};
271+
}
272+
}
273+
213274
/**
214275
* Gets all of the persisted metrics related to a given ping.
215276
*
@@ -228,13 +289,21 @@ class MetricsDatabase {
228289
await this.clear(Lifetime.Ping, ping);
229290
}
230291

231-
const response: Metrics = { ...pingData };
232-
for (const data of [userData, appData]) {
292+
const response: Metrics = {};
293+
for (const data of [userData, pingData, appData]) {
233294
for (const metricType in data) {
234-
response[metricType] = {
235-
...response[metricType],
236-
...data[metricType]
237-
};
295+
for (const metricId in data[metricType]) {
296+
if (metricId.includes("/")) {
297+
// While labeled data is stored within the subtype storage (e.g. counter storage), it
298+
// needs to live in a different section of the ping payload (e.g. `labeled_counter`).
299+
this.processLabeledMetric(response, metricType, metricId, data[metricType][metricId]);
300+
} else {
301+
response[metricType] = {
302+
...response[metricType],
303+
[metricId]: data[metricType][metricId]
304+
};
305+
}
306+
}
238307
}
239308
}
240309

glean/src/core/metrics/index.ts

+55-5
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import { JSONValue } from "../utils";
5+
import { isUndefined, JSONValue } from "../utils";
66
import Glean from "../glean";
7+
import LabeledMetricType from "./types/labeled";
78

89
/**
910
* The Metric class describes the shared behaviour amongst concrete metrics.
@@ -101,7 +102,14 @@ export interface CommonMetricData {
101102
// Whether or not the metric is disabled.
102103
//
103104
// Disabled metrics are never recorded.
104-
readonly disabled: boolean
105+
readonly disabled: boolean,
106+
// Dynamic label.
107+
//
108+
// When a labeled metric factory creates the specific metric to be recorded to,
109+
// dynamic labels are stored in the metadata so that we can validate them when
110+
// the Glean singleton is available (because metrics can be recorded before Glean
111+
// is initialized).
112+
dynamicLabel?: string
105113
}
106114

107115
/**
@@ -114,6 +122,7 @@ export abstract class MetricType implements CommonMetricData {
114122
readonly sendInPings: string[];
115123
readonly lifetime: Lifetime;
116124
readonly disabled: boolean;
125+
dynamicLabel?: string;
117126

118127
constructor(type: string, meta: CommonMetricData) {
119128
this.type = type;
@@ -123,21 +132,41 @@ export abstract class MetricType implements CommonMetricData {
123132
this.sendInPings = meta.sendInPings;
124133
this.lifetime = meta.lifetime as Lifetime;
125134
this.disabled = meta.disabled;
135+
this.dynamicLabel = meta.dynamicLabel;
126136
}
127137

128138
/**
129-
* This metric's unique identifier, including the category and name.
139+
* The metric's base identifier, including the category and name, but not the label.
130140
*
131-
* @returns The generated identifier.
141+
* @returns The generated identifier. If `category` is empty, it's ommitted. Otherwise,
142+
* it's the combination of the metric's `category` and `name`.
132143
*/
133-
get identifier(): string {
144+
baseIdentifier(): string {
134145
if (this.category.length > 0) {
135146
return `${this.category}.${this.name}`;
136147
} else {
137148
return this.name;
138149
}
139150
}
140151

152+
/**
153+
* The metric's unique identifier, including the category, name and label.
154+
*
155+
* @returns The generated identifier. If `category` is empty, it's ommitted. Otherwise,
156+
* it's the combination of the metric's `category`, `name` and `label`.
157+
*/
158+
async getAsyncIdentifier(): Promise<string> {
159+
const baseIdentifier = this.baseIdentifier();
160+
161+
// We need to use `isUndefined` and cannot use `(this.dynamicLabel)` because we want
162+
// empty strings to propagate as a dynamic labels, so that erros are potentially recorded.
163+
if (!isUndefined(this.dynamicLabel)) {
164+
return await LabeledMetricType.getValidDynamicLabel(this);
165+
} else {
166+
return baseIdentifier;
167+
}
168+
}
169+
141170
/**
142171
* Verify whether or not this metric instance should be recorded.
143172
*
@@ -147,3 +176,24 @@ export abstract class MetricType implements CommonMetricData {
147176
return (Glean.isUploadEnabled() && !this.disabled);
148177
}
149178
}
179+
180+
/**
181+
* This is no-op internal metric representation.
182+
*
183+
* This can be used to instruct the validators to simply report
184+
* whatever is stored internally, without performing any specific
185+
* validation.
186+
*/
187+
export class PassthroughMetric extends Metric<JSONValue, JSONValue> {
188+
constructor(v: unknown) {
189+
super(v);
190+
}
191+
192+
validate(v: unknown): v is JSONValue {
193+
return true;
194+
}
195+
196+
payload(): JSONValue {
197+
return this._inner;
198+
}
199+
}

glean/src/core/metrics/utils.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import { Metric } from "./index";
5+
import { Metric, PassthroughMetric } from "./index";
66
import { JSONValue } from "../utils";
77

88
import { BooleanMetric } from "./types/boolean";
@@ -20,6 +20,7 @@ const METRIC_MAP: {
2020
"boolean": BooleanMetric,
2121
"counter": CounterMetric,
2222
"datetime": DatetimeMetric,
23+
"labeled_counter": PassthroughMetric,
2324
"string": StringMetric,
2425
"uuid": UUIDMetric,
2526
});

0 commit comments

Comments
 (0)