diff --git a/functions/README.md b/functions/README.md index 66082ff78d..97d5b1cf16 100644 --- a/functions/README.md +++ b/functions/README.md @@ -26,8 +26,9 @@ environment. * [Cloud Datastore](datastore/) * [Cloud Pub/Sub](pubsub/) * [Dependencies](uuid/) +* [Error Reporting](errorreporting/) * [HTTP](http/) -* [Logging](log/) +* [Logging & Monitoring](log/) * [Modules](module/) * [OCR (Optical Character Recognition)](ocr/) * [SendGrid](sendgrid/) diff --git a/functions/errorreporting/index.js b/functions/errorreporting/index.js new file mode 100644 index 0000000000..b16330bb2b --- /dev/null +++ b/functions/errorreporting/index.js @@ -0,0 +1,141 @@ +// Copyright 2016, Google, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +// [START setup] +var gcloud = require('gcloud'); + +// Get a reference to the StackDriver Logging component +var logging = gcloud.logging(); +// [END setup] + +// [START reportDetailedError] +var reportDetailedError = require('./report'); +// [END reportDetailedError] + +// [START helloSimpleErrorReport] +/** + * Report an error to StackDriver Error Reporting. Writes the minimum data + * required for the error to be picked up by StackDriver Error Reporting. + * + * @param {Error} err The Error object to report. + * @param {Function} callback Callback function. + */ +function reportError (err, callback) { + // This is the name of the StackDriver log stream that will receive the log + // entry. This name can be any valid log stream name, but must contain "err" + // in order for the error to be picked up by StackDriver Error Reporting. + var logName = 'errors'; + var log = logging.log(logName); + + // https://cloud.google.com/logging/docs/api/ref_v2beta1/rest/v2beta1/MonitoredResource + var monitoredResource = { + type: 'cloud_function', + labels: { + function_name: process.env.FUNCTION_NAME + } + }; + + // https://cloud.google.com/error-reporting/reference/rest/v1beta1/ErrorEvent + var errorEvent = { + message: err.stack, + serviceContext: { + service: 'cloud_function:' + process.env.FUNCTION_NAME, + version: require('./package.json').version || 'unknown' + } + }; + + // Write the error log entry + log.write(log.entry(monitoredResource, errorEvent), callback); +} +// [END helloSimpleErrorReport] + +// [START helloSimpleError] +/** + * HTTP Cloud Function. + * + * @param {Object} req Cloud Function request object. + * @param {Object} res Cloud Function response object. + */ +exports.helloSimpleError = function helloSimpleError (req, res) { + try { + if (req.method !== 'GET') { + var error = new Error('Only GET requests are accepted!'); + error.code = 405; + throw error; + } + // All is good, respond to the HTTP request + return res.send('Hello World!'); + } catch (err) { + // Report the error + return reportError(err, function () { + // Now respond to the HTTP request + return res.status(error.code || 500).send(err.message); + }); + } +}; +// [END helloSimpleError] + +// [START helloHttpError] +/** + * HTTP Cloud Function. + * + * @param {Object} req Cloud Function request object. + * @param {Object} res Cloud Function response object. + */ +exports.helloHttpError = function helloHttpError (req, res) { + try { + if (req.method !== 'POST' && req.method !== 'GET') { + var error = new Error('Only POST and GET requests are accepted!'); + error.code = 405; + throw error; + } + // All is good, respond to the HTTP request + return res.send('Hello ' + (req.body.message || 'World') + '!'); + } catch (err) { + // Set the response status code before reporting the error + res.status(err.code || 500); + // Report the error + return reportDetailedError(err, req, res, function () { + // Now respond to the HTTP request + return res.send(err.message); + }); + } +}; +// [END helloHttpError] + +// [START helloBackgroundError] +/** + * Background Cloud Function. + * + * @param {Object} context Cloud Function context object. + * @param {Object} data Request data, provided by a trigger. + * @param {string} data.message Message, provided by the trigger. + */ +exports.helloBackgroundError = function helloBackgroundError (context, data) { + try { + if (!data.message) { + throw new Error('"message" is required!'); + } + // All is good, respond with a message + return context.success('Hello World!'); + } catch (err) { + // Report the error + return reportDetailedError(err, function () { + // Now finish mark the execution failure + return context.failure(err.message); + }); + } +}; +// [END helloBackgroundError] diff --git a/functions/errorreporting/package.json b/functions/errorreporting/package.json new file mode 100644 index 0000000000..4fc19844cf --- /dev/null +++ b/functions/errorreporting/package.json @@ -0,0 +1,15 @@ +{ + "name": "nodejs-docs-samples-functions-errorreporting", + "description": "Node.js samples found on https://cloud.google.com", + "version": "0.0.1", + "private": true, + "license": "Apache Version 2.0", + "author": "Google Inc.", + "repository": { + "type": "git", + "url": "https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git" + }, + "dependencies": { + "gcloud": "^0.36.0" + } +} diff --git a/functions/errorreporting/report.js b/functions/errorreporting/report.js new file mode 100644 index 0000000000..ae8557b1c3 --- /dev/null +++ b/functions/errorreporting/report.js @@ -0,0 +1,126 @@ +// Copyright 2016, Google, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +var gcloud = require('gcloud'); +var logging = gcloud.logging(); + +// [START helloHttpError] +/** + * Report an error to StackDriver Error Reporting. Writes up to the maximum data + * accepted by StackDriver Error Reporting. + * + * @param {Error} err The Error object to report. + * @param {Object} [req] Request context, if any. + * @param {Object} [res] Response context, if any. + * @param {Object} [options] Additional context, if any. + * @param {Function} callback Callback function. + */ +function reportDetailedError (err, req, res, options, callback) { + if (typeof req === 'function') { + callback = req; + req = null; + res = null; + options = {}; + } else if (typeof options === 'function') { + callback = options; + options = {}; + } + options || (options = {}); + + var FUNCTION_NAME = process.env.FUNCTION_NAME; + var log = logging.log('errors'); + + // MonitoredResource + // See https://cloud.google.com/logging/docs/api/ref_v2beta1/rest/v2beta1/MonitoredResource + var resource = { + // MonitoredResource.type + type: 'cloud_function', + // MonitoredResource.labels + labels: { + function_name: FUNCTION_NAME + } + }; + if (typeof options.region === 'string') { + resource.labels.region = options.region; + } + if (typeof options.projectId === 'string') { + resource.labels.projectId = options.projectId; + } + + var context = {}; + if (typeof options.user === 'string') { + // ErrorEvent.context.user + context.user = options.user; + } + if (req && res) { + // ErrorEvent.context.httpRequest + context.httpRequest = { + method: req.method, + url: req.originalUrl, + userAgent: typeof req.get === 'function' ? req.get('user-agent') : 'unknown', + referrer: '', + remoteIp: req.ip + }; + if (typeof res.statusCode === 'number') { + context.httpRequest.responseStatusCode = res.statusCode; + } + } + if (!(err instanceof Error) || typeof err.stack !== 'string') { + // ErrorEvent.context.reportLocation + context.reportLocation = { + filePath: typeof options.filePath === 'string' ? options.filePath : 'unknown', + lineNumber: typeof options.lineNumber === 'number' ? options.lineNumber : 0, + functionName: typeof options.functionName === 'string' ? options.functionName : 'unknown' + }; + } + + try { + if (options.version === undefined) { + var pkg = require('./package.json'); + options.version = pkg.version; + } + } catch (err) {} + if (options.version === undefined) { + options.version = 'unknown'; + } + + // ErrorEvent + // See https://cloud.google.com/error-reporting/reference/rest/v1beta1/ErrorEvent + var structPayload = { + // ErrorEvent.serviceContext + serviceContext: { + // ErrorEvent.serviceContext.service + service: 'cloud_function:' + FUNCTION_NAME, + // ErrorEvent.serviceContext.version + version: '' + options.version + }, + // ErrorEvent.context + context: context + }; + + // ErrorEvent.message + if (err instanceof Error && typeof err.stack === 'string') { + structPayload.message = err.stack; + } else if (typeof err === 'string') { + structPayload.message = err; + } else if (typeof err.message === 'string') { + structPayload.message = err.message; + } + + log.write(log.entry(resource, structPayload), callback); +} +// [END helloHttpError] + +module.exports = reportDetailedError; diff --git a/functions/log/index.js b/functions/log/index.js index 8d5cd55a12..54c452661c 100644 --- a/functions/log/index.js +++ b/functions/log/index.js @@ -14,8 +14,82 @@ 'use strict'; // [START log] -exports.helloWorld = function (context, data) { +exports.helloWorld = function helloWorld (context, data) { console.log('I am a log entry!'); context.success(); }; // [END log] + +exports.retrieve = function retrieve () { + // [START retrieve] + // By default, gcloud will authenticate using the service account file specified + // by the GOOGLE_APPLICATION_CREDENTIALS environment variable and use the + // project specified by the GCLOUD_PROJECT environment variable. See + // https://googlecloudplatform.github.io/gcloud-node/#/docs/guides/authentication + var gcloud = require('gcloud'); + var logging = gcloud.logging(); + + // Retrieve the latest Cloud Function log entries + // See https://googlecloudplatform.github.io/gcloud-node/#/docs/logging + logging.getEntries({ + pageSize: 10, + filter: 'resource.type="cloud_function"' + }, function (err, entries) { + if (err) { + console.error(err); + } else { + console.log(entries); + } + }); + // [END retrieve] +}; + +exports.getMetrics = function getMetrics () { + // [START getMetrics] + var google = require('googleapis'); + var monitoring = google.monitoring('v3'); + + google.auth.getApplicationDefault(function (err, authClient) { + if (err) { + return console.error('Authentication failed', err); + } + if (authClient.createScopedRequired && authClient.createScopedRequired()) { + var scopes = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/monitoring', + 'https://www.googleapis.com/auth/monitoring.read', + 'https://www.googleapis.com/auth/monitoring.write' + ]; + authClient = authClient.createScoped(scopes); + } + + // Format a date according to RFC33339 with milliseconds format + function formatDate (date) { + return JSON.parse(JSON.stringify(date).replace('Z', '000Z')); + } + + // Create two datestrings, a start and end range + var oneWeekAgo = new Date(); + var now = new Date(); + oneWeekAgo.setHours(oneWeekAgo.getHours() - (7 * 24)); + oneWeekAgo = formatDate(oneWeekAgo); + now = formatDate(now); + + monitoring.projects.timeSeries.list({ + auth: authClient, + // There is also cloudfunctions.googleapis.com/function/execution_count + filter: 'metric.type="cloudfunctions.googleapis.com/function/execution_times"', + pageSize: 10, + 'interval.startTime': oneWeekAgo, + 'interval.endTime': now, + name: 'projects/' + process.env.GCLOUD_PROJECT + }, function (err, results) { + if (err) { + console.error(err); + } else { + console.log(results.timeSeries); + } + }); + }); + // [END getMetrics] +}; diff --git a/test/functions/log.test.js b/test/functions/log.test.js index 09d127410b..28cd3c5b23 100644 --- a/test/functions/log.test.js +++ b/test/functions/log.test.js @@ -15,20 +15,114 @@ var test = require('ava'); var sinon = require('sinon'); -var logSample = require('../../functions/log'); +var proxyquire = require('proxyquire').noCallThru(); + +var authClient = {}; + +function getSample () { + var auth = { + getApplicationDefault: sinon.stub().callsArgWith(0, null, authClient) + }; + var monitoring = { + projects: { + timeSeries: { + list: sinon.stub().callsArgWith(1, null, { + timeSeries: 'series' + }) + } + } + }; + var logging = { + getEntries: sinon.stub().callsArgWith(1, null, 'entries') + }; + return { + sample: proxyquire('../../functions/log', { + googleapis: { + auth: auth, + monitoring: sinon.stub().returns(monitoring) + }, + gcloud: { + logging: sinon.stub().returns(logging) + } + }), + mocks: { + auth: auth, + monitoring: monitoring, + logging: logging + } + }; +} + +test.before(function () { + sinon.stub(console, 'error'); + sinon.stub(console, 'log'); +}); test('should write to log', function (t) { var expectedMsg = 'I am a log entry!'; - sinon.spy(console, 'log'); - - logSample.helloWorld({ + getSample().sample.helloWorld({ success: function (result) { t.is(result, undefined); - t.is(console.log.calledOnce, true); + t.is(console.log.called, true); t.is(console.log.calledWith(expectedMsg), true); }, failure: t.fail }); +}); + +test('retrieve: should retrieve logs', function (t) { + var logSample = getSample(); + logSample.sample.retrieve(); + t.is(console.log.calledWith('entries'), true); +}); + +test('retrieve: handles error', function (t) { + var expectedMsg = 'entries error'; + var logSample = getSample(); + logSample.mocks.logging.getEntries = sinon.stub().callsArgWith(1, expectedMsg); + logSample.sample.retrieve(); + t.is(console.error.calledWith(expectedMsg), true); +}); + +test('getMetrics: should retrieve metrics', function (t) { + var logSample = getSample(); + logSample.sample.getMetrics(); + t.is(console.log.calledWith('series'), true); +}); + +test('getMetrics: creates with scope', function (t) { + var authClient = { + createScopedRequired: sinon.stub().returns(true), + createScoped: sinon.stub().returns('foo') + }; + var logSample = getSample(); + logSample.mocks.auth.getApplicationDefault = sinon.stub().callsArgWith(0, null, authClient); + logSample.sample.getMetrics(); + t.deepEqual(authClient.createScoped.firstCall.args[0], [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/monitoring', + 'https://www.googleapis.com/auth/monitoring.read', + 'https://www.googleapis.com/auth/monitoring.write' + ]); +}); + +test('getMetrics: handles auth error', function (t) { + var expectedMsg = 'auth error'; + var logSample = getSample(); + logSample.mocks.auth.getApplicationDefault = sinon.stub().callsArgWith(0, expectedMsg); + logSample.sample.getMetrics(); + t.is(console.error.calledWith('Authentication failed', expectedMsg), true); +}); + +test('getMetrics: handles time series error', function (t) { + var expectedMsg = 'time series error'; + var logSample = getSample(); + logSample.mocks.monitoring.projects.timeSeries.list = sinon.stub().callsArgWith(1, expectedMsg); + logSample.sample.getMetrics(); + t.is(console.error.calledWith(expectedMsg), true); +}); +test.after(function () { + console.error.restore(); console.log.restore(); });