From 5a2b1875382b18b00c65e8d1ca097a2aad609ff3 Mon Sep 17 00:00:00 2001 From: Vasco Santos Date: Wed, 8 Dec 2021 14:56:56 +0100 Subject: [PATCH] feat: sentry to gateway --- .github/workflows/gateway.yml | 2 + .github/workflows/release.yml | 3 ++ packages/gateway/README.md | 6 +++ packages/gateway/ava.config.js | 8 +++ packages/gateway/package.json | 13 +++-- packages/gateway/scripts/cli.js | 74 +++++++++++++++++++++++++++ packages/gateway/src/error-handler.js | 39 +++++++++++++- packages/gateway/src/errors.js | 25 +++++++++ packages/gateway/src/index.js | 34 ++++++++---- packages/gateway/src/utils/cid.js | 6 ++- packages/gateway/test/index.spec.js | 9 ++-- packages/gateway/wrangler.toml | 4 +- 12 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 packages/gateway/ava.config.js create mode 100644 packages/gateway/scripts/cli.js create mode 100644 packages/gateway/src/errors.js diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml index 9646bcbf750..a2e9a9d72ab 100644 --- a/.github/workflows/gateway.yml +++ b/.github/workflows/gateway.yml @@ -35,6 +35,8 @@ jobs: - uses: bahmutov/npm-install@v1 - name: Publish app uses: cloudflare/wrangler-action@1.3.0 + env: + SENTRY_TOKEN: ${{secrets.SENTRY_TOKEN}} with: apiToken: ${{secrets.CF_API_TOKEN }} workingDirectory: 'packages/gateway' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2be9774345f..37ef2c18b0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,6 +120,9 @@ jobs: - name: Gateway - Deploy if: ${{ steps.tag-release.outputs.release_created && matrix.package == 'gateway' }} uses: cloudflare/wrangler-action@1.3.0 + env: + SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} + SENTRY_UPLOAD: ${{ secrets.SENTRY_UPLOAD }} with: apiToken: ${{ secrets.CF_API_TOKEN }} workingDirectory: 'packages/gateway' diff --git a/packages/gateway/README.md b/packages/gateway/README.md index 35256af13a1..a0289db74a7 100644 --- a/packages/gateway/README.md +++ b/packages/gateway/README.md @@ -21,6 +21,12 @@ One time set up of your cloudflare worker subdomain for dev: account_id = "" ``` +- Add secrets + + ```sh + wrangler secret put SENTRY_DSN --env $(whoami) # Get from Sentry (not required for dev) + ``` + - `npm run publish` - Publish the worker under your env. An alias for `wrangler publish --env $(whoami)` - `npm start` - Run the worker in dev mode. An alias for `wrangler dev --env $(whoami) diff --git a/packages/gateway/ava.config.js b/packages/gateway/ava.config.js new file mode 100644 index 00000000000..e8574124804 --- /dev/null +++ b/packages/gateway/ava.config.js @@ -0,0 +1,8 @@ +export default { + nonSemVerExperiments: { + configurableModuleFormat: true, + }, + files: ['test/*.spec.js'], + timeout: '5m', + nodeArguments: ['--experimental-vm-modules'], +} diff --git a/packages/gateway/package.json b/packages/gateway/package.json index d2583ce5d53..24212fcea70 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -4,9 +4,9 @@ "description": "IPFS gateway for nft.storage", "private": true, "type": "module", - "main": "./dist/index.js", + "module": "./dist/index.mjs", "scripts": { - "build": "esbuild --bundle --sourcemap --outdir=dist ./src/index.js", + "build": "node scripts/cli.js build", "dev": "miniflare --watch --debug", "deploy": "wrangler publish --env production", "pretest": "npm run build ", @@ -15,14 +15,19 @@ "mock:ipfs.io": "smoke -p 9081 test/mocks/ipfs.io" }, "dependencies": { - "multiformats": "^9.5.2" + "multiformats": "^9.5.2", + "toucan-js": "^2.4.1" }, "devDependencies": { + "@sentry/cli": "^1.71.0", "ava": "^3.15.0", "esbuild": "^0.14.2", + "git-rev-sync": "^3.0.1", "miniflare": "^2.0.0-rc.2", "npm-run-all": "^4.1.5", - "smoke": "^3.1.1" + "sade": "^1.7.4", + "smoke": "^3.1.1", + "toucan-js": "^2.5.0" }, "author": "Vasco Santos ", "license": "(Apache-2.0 AND MIT)" diff --git a/packages/gateway/scripts/cli.js b/packages/gateway/scripts/cli.js new file mode 100644 index 00000000000..8b245ef8f78 --- /dev/null +++ b/packages/gateway/scripts/cli.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import sade from 'sade' +import { build } from 'esbuild' +import git from 'git-rev-sync' +import Sentry from '@sentry/cli' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const pkg = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8') +) + +const prog = sade('gateway') + +prog + .command('build') + .describe('Build the worker.') + .option('--env', 'Environment', 'dev') + .action(async (opts) => { + try { + const version = `${pkg.name}@${pkg.version}-${opts.env}+${git.short( + __dirname + )}` + + await build({ + entryPoints: [path.join(__dirname, '../src/index.js')], + bundle: true, + format: 'esm', + outfile: 'dist/index.mjs', + legalComments: 'external', + define: { + VERSION: JSON.stringify(version), + COMMITHASH: JSON.stringify(git.long(__dirname)), + BRANCH: JSON.stringify(git.branch(__dirname)), + ENV: opts.env || 'dev', + global: 'globalThis', + }, + minify: opts.env === 'dev' ? false : true, + sourcemap: true, + }) + + // Sentry release and sourcemap upload + if (process.env.SENTRY_UPLOAD === 'true') { + const cli = new Sentry(undefined, { + authToken: process.env.SENTRY_TOKEN, + org: 'protocol-labs-it', + project: 'nft-gateway', + dist: git.short(__dirname), + }) + + await cli.releases.new(version) + await cli.releases.setCommits(version, { + auto: true, + ignoreEmpty: true, + ignoreMissing: true, + }) + await cli.releases.uploadSourceMaps(version, { + include: ['./dist'], + urlPrefix: '/', + }) + await cli.releases.finalize(version) + await cli.releases.newDeploy(version, { + env: opts.env, + }) + } + } catch (err) { + console.error(err) + process.exit(1) + } + }) + +prog.parse(process.argv) diff --git a/packages/gateway/src/error-handler.js b/packages/gateway/src/error-handler.js index 3b6cabafd24..967a5378634 100644 --- a/packages/gateway/src/error-handler.js +++ b/packages/gateway/src/error-handler.js @@ -1,10 +1,14 @@ +import Toucan from 'toucan-js' + +import pkg from '../package.json' import { JSONResponse } from './utils/json-response.js' /** * @param {Error & {status?: number;code?: string;}} err + * @param {Request} request + * @param {import('./index').Env} env */ -export function errorHandler(err) { - // TODO: setup sentry +export function errorHandler(err, request, env) { console.error(err.stack) let error = { @@ -13,5 +17,36 @@ export function errorHandler(err) { } let status = err.status || 500 + const sentry = getSentry(request, env) + if (sentry && status >= 500) { + sentry.captureException(err) + } + return new JSONResponse(error, { status }) } + +/** + * Get sentry instance if configured + * + * @param {Request} request + * @param {import('./index').Env} env + */ +function getSentry(request, env) { + if (!env.SENTRY_DSN) { + return + } + + return new Toucan({ + request, + dsn: env.SENTRY_DSN, + allowedHeaders: ['user-agent', 'x-client'], + allowedSearchParams: /(.*)/, + debug: false, + environment: env.ENV || 'dev', + rewriteFrames: { + root: '/', + }, + release: env.VERSION, + pkg, + }) +} diff --git a/packages/gateway/src/errors.js b/packages/gateway/src/errors.js new file mode 100644 index 00000000000..8e945d9ca65 --- /dev/null +++ b/packages/gateway/src/errors.js @@ -0,0 +1,25 @@ +export class InvalidIpfsPathError extends Error { + /** + * @param {string} cid + */ + constructor(cid) { + super(`invalid ipfs path: invalid path "/ipfs/${cid}/"`) + this.name = 'InvalidIpfsPath' + this.status = 400 + this.code = InvalidIpfsPathError.CODE + } +} +InvalidIpfsPathError.CODE = 'ERROR_INVALID_IPFS_PATH' + +export class InvalidUrlError extends Error { + /** + * @param {string} url + */ + constructor(url) { + super(`invalid url: ${url}`) + this.name = 'InvalidUrl' + this.status = 400 + this.code = InvalidUrlError.CODE + } +} +InvalidUrlError.CODE = 'ERROR_INVALID_URL' diff --git a/packages/gateway/src/index.js b/packages/gateway/src/index.js index ceb8905a56e..412a67f40fd 100644 --- a/packages/gateway/src/index.js +++ b/packages/gateway/src/index.js @@ -2,12 +2,21 @@ import { addCorsHeaders } from './cors.js' import { errorHandler } from './error-handler.js' import { getCidFromSubdomainUrl } from './utils/cid.js' +/** + * @typedef {Object} Env + * @property {string} IPFS_GATEWAY + * @property {string} VERSION + * @property {string} ENV + * @property {string} [SENTRY_DSN] + */ + /** * Handle gateway request * @param {Request} request + * @param {Env} env */ -async function handleRequest(request) { - const publicGatewayUrl = new URL('ipfs', IPFS_GATEWAY) +async function handleRequest(request, env) { + const publicGatewayUrl = new URL('ipfs', env.IPFS_GATEWAY) const cid = getCidFromSubdomainUrl(request.url) const response = await fetch(`${publicGatewayUrl.toString()}/${cid}`) @@ -18,15 +27,18 @@ async function handleRequest(request) { /** * @param {Error} error * @param {Request} request + * @param {Env} env */ -function serverError(error, request) { - return addCorsHeaders(request, errorHandler(error)) +function serverError(error, request, env) { + return addCorsHeaders(request, errorHandler(error, request, env)) } -addEventListener('fetch', (event) => { - event.respondWith( - handleRequest(event.request).catch((error) => - serverError(error, event.request) - ) - ) -}) +export default { + async fetch(request, env) { + try { + return await handleRequest(request, env) + } catch (error) { + return serverError(error, request, env) + } + }, +} diff --git a/packages/gateway/src/utils/cid.js b/packages/gateway/src/utils/cid.js index 4b3658e6bd6..dcfb37fed0b 100644 --- a/packages/gateway/src/utils/cid.js +++ b/packages/gateway/src/utils/cid.js @@ -1,5 +1,7 @@ import { CID } from 'multiformats/cid' +import { InvalidIpfsPathError, InvalidUrlError } from '../errors.js' + /** * Parse subdomain URL and return cid * @@ -10,7 +12,7 @@ export function getCidFromSubdomainUrl(url) { const splitUrl = nUrl.split('.ipfs.') if (!splitUrl.length) { - throw new Error(`invalid url: ${url}`) + throw new InvalidUrlError(url) } return normalizeCid(splitUrl[0]) @@ -26,6 +28,6 @@ export function normalizeCid(cid) { const c = CID.parse(cid) return c.toV1().toString() } catch (err) { - throw new Error(`invalid ipfs path: invalid path "/ipfs/${cid}/": ${err}`) + throw new InvalidIpfsPathError(cid) } } diff --git a/packages/gateway/test/index.spec.js b/packages/gateway/test/index.spec.js index fca6d6745b5..856db86d283 100644 --- a/packages/gateway/test/index.spec.js +++ b/packages/gateway/test/index.spec.js @@ -1,6 +1,8 @@ import test from 'ava' import { Miniflare } from 'miniflare' +import { InvalidIpfsPathError } from '../src/errors.js' + test.beforeEach((t) => { // Create a new Miniflare environment for each test const mf = new Miniflare({ @@ -13,6 +15,7 @@ test.beforeEach((t) => { // This will override the option in wrangler.toml. buildCommand: undefined, wranglerConfigEnv: 'test', + modules: true, }) t.context = { @@ -25,13 +28,13 @@ test('Fails when invalid cid is provided', async (t) => { const invalidCid = 'bafy' const response = await mf.dispatchFetch(`${invalidCid}.ipfs.localhost:8787`) - t.is(response.status, 500) + t.is(response.status, 400) const jsonResponse = await response.json() - t.is(jsonResponse.code, 'HTTP_ERROR') + t.is(jsonResponse.code, InvalidIpfsPathError.CODE) t.is( jsonResponse.message, - `invalid ipfs path: invalid path "/ipfs/${invalidCid}/": SyntaxError: Unexpected end of data` + `invalid ipfs path: invalid path "/ipfs/${invalidCid}/"` ) }) diff --git a/packages/gateway/wrangler.toml b/packages/gateway/wrangler.toml index 6e958935790..7e9a1e23b0c 100644 --- a/packages/gateway/wrangler.toml +++ b/packages/gateway/wrangler.toml @@ -12,7 +12,9 @@ compatibility_date = "2021-12-03" [build] command = "npm run build" [build.upload] -format = "service-worker" +format = "modules" +dir = "dist" +main = "index.mjs" # PROD! [env.production]