functions/index.js (145 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 https://mozilla.org/MPL/2.0/. */ const functions = require('firebase-functions'); const admin = require('firebase-admin'); admin.initializeApp(); const { gzip, ungzip } = require('node-gzip'); const fs = require('fs'); const util = require('util'); async function getJsonValidator() { console.log("Creating schema validator...") // first, try fetching latest schema from Firestore const db = admin.firestore(); const latestSchema = await db.collection('glean_schemas').doc('latest').get(); let schema; let schemaVersion; if (latestSchema.exists) { console.log('Using schema from Firestore'); const data = latestSchema.data(); schema = data.schema; schemaVersion = data.deployTimestamp; } else { // if there's no schema in Firestore, fall back to bundled one console.log("Using bundled schema"); const readFile = util.promisify(fs.readFile); schema = await readFile('schema/glean.1.schema.json'); schemaVersion = "bundled"; } const gleanSchema = schema; const Ajv = require('ajv'); const ajv = new Ajv({unknownFormats: ["datetime", "json"]}); ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json')); return { validator: ajv.compile(JSON.parse(gleanSchema.toString())), schemaVersion: schemaVersion, }; } const schemaValidator = getJsonValidator(); /** * Push ping data to Firestore */ async function storePing(pubSubMessage, rawPing, error) { const db = admin.firestore(); const batch = db.batch(); let pingJson = null; let os = "???"; try { // TODO: make sure this is safe if some fields are missing pingJson = JSON.parse(rawPing); os = sanitize(pingJson.client_info.os) + " " + sanitize(pingJson.client_info.os_version); } catch (e) { // this is validation error console.error(`JSON parse error: ${e}, raw ping was: ${rawPing}`); } const appName = sanitize(pubSubMessage.attributes.document_namespace); const geo = sanitize(pubSubMessage.attributes.geo_city) + ", " + sanitize(pubSubMessage.attributes.geo_country); const debugId = sanitize(pubSubMessage.attributes.x_debug_id); const clientRef = db.collection("clients").doc(debugId); batch.set(clientRef, { appName: appName, debugId: debugId, geo: geo, lastActive: pubSubMessage.publishTime, os: os, }); const pingType = pubSubMessage.attributes.document_type; const pingRef = db.collection("pings").doc(pubSubMessage.attributes.document_id); const errorFields = await revalidateAndGetErrorFields(pubSubMessage, rawPing, error); const baseFields = { addedAt: pubSubMessage.publishTime, debugId: debugId, payload: rawPing, pingType: pingType, }; batch.set(pingRef, { ...baseFields, ...errorFields, }); return batch.commit(); } function sanitize(value) { // eslint-disable-next-line no-eq-null return value == null ? "???" : value; } /** * Builds set of error fields. * If provided ping originates from error stream and is a Glean one, tries to validate it against Glean schema. */ async function revalidateAndGetErrorFields(pubSubMessage, rawPing, error) { if (error) { // Glean ping from unreleased or development app - let's validate against Glean schema const validator = await schemaValidator; const validate = validator.validator; const schemaVersion = validator.schemaVersion; let errorMessage = null; let valid = false; try { valid = validate(JSON.parse(rawPing)); errorMessage = JSON.stringify(validate.errors); } catch (e) { errorMessage = e.toString(); } return valid ? { warning: 'JSON_VALIDATION_IN_DEBUG_VIEW', debugViewSchemaVersion: schemaVersion, } : { error: true, errorType: 'JSON_VALIDATION_ERROR_DEBUG_VIEW', errorMessage: errorMessage, debugViewSchemaVersion: schemaVersion, }; } else { return error ? { error: true, errorType: pubSubMessage.attributes.error_type, errorMessage: pubSubMessage.attributes.error_message, } : {}; } } async function handlePost(req, res, error) { const pubSubMessage = req.body.message; const debugId = pubSubMessage.attributes.x_debug_id; // TODO: we should create a list of Glean applications and check the namespace below against it // const namespace = pubSubMessage.attributes.document_namespace; // const gleanDebugPing = namespace === "glean" && debugId; const gleanDebugPing = debugId; // document_id is used as the key in Firestore, so we can't store a ping without it if (gleanDebugPing && pubSubMessage.attributes.document_id != null) { // eslint-disable-line no-eq-null const pingPayload = Buffer.from(pubSubMessage.data, 'base64'); return ungzip(pingPayload).then((decompressed) => { return storePing(pubSubMessage, decompressed.toString(), error); }); } else { return Promise.resolve(); } } /** * Cloud Function to be triggered by Pub/Sub push subscription * that stores Glean debug pings in Firestore. */ exports.debugPing = functions.https.onRequest((req, res) => { switch (req.method) { case 'GET': // Domain ownership verification return res.send(`<html><head><meta name="google-site-verification" content="FGveh31iPHURsXECLhzcauxkjdK3x3Sy8KA7RBlVz90" /></head><body></body></html>`) case 'POST': return handlePost(req, res, false).then(() => { // A response with 204 status code is considered as an implicit acknowledgement. return res.status(204).end(); }); default: return res.status(403).send('Forbidden!'); } }); /** * Cloud Function to be triggered by Pub/Sub push subscription * that stores Glean ping validation errors in Firestore. */ exports.decoderError = functions.https.onRequest((req, res) => { switch (req.method) { case 'GET': // Domain ownership verification return res.send(`<html><head><meta name="google-site-verification" content="FGveh31iPHURsXECLhzcauxkjdK3x3Sy8KA7RBlVz90" /></head><body></body></html>`) case 'POST': return handlePost(req, res, true).then(() => { // A response with 204 status code is considered as an implicit acknowledgement. return res.status(204).end(); }); default: return res.status(403).send('Forbidden!'); } }); const schemaLoader = require('./schemaLoader'); exports.gleanSchemaLoader = schemaLoader.gleanSchemaLoader; const garbageCollector = require('./garbageCollector'); exports.removeOutdatedPings = garbageCollector.removeOutdatedPings;