├── .editorconfig ├── .gitattributes ├── .github └── dependabot.yml ├── .gitignore ├── .npmrc ├── LICENSE.md ├── README.md ├── package.json ├── src ├── analytics.js ├── index.js └── server.js ├── test └── index.js └── vercel.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | max_line_length = 80 13 | indent_brace_style = 1TBS 14 | spaces_around_operators = true 15 | quote_type = auto 16 | 17 | [package.json] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: 'github-actions' 8 | directory: '/' 9 | schedule: 10 | # Check for updates to GitHub Actions every weekday 11 | interval: 'daily' 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # npm 3 | ############################ 4 | node_modules 5 | npm-debug.log 6 | .node_history 7 | yarn.lock 8 | package-lock.json 9 | 10 | ############################ 11 | # tmp, editor & OS files 12 | ############################ 13 | .tmp 14 | *.swo 15 | *.swp 16 | *.swn 17 | *.swm 18 | .DS_Store 19 | *# 20 | *~ 21 | .idea 22 | *sublime* 23 | nbproject 24 | 25 | ############################ 26 | # Tests 27 | ############################ 28 | testApp 29 | coverage 30 | .nyc_output 31 | 32 | ############################ 33 | # Other 34 | ############################ 35 | .env 36 | .envrc 37 | bin/darwin 38 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | unsafe-perm=true 2 | save-prefix=~ 3 | save=false 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2020 Kiko Beats (kikobeats.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | microlink logo 3 | microlink logo 4 |
5 |
6 |
7 | 8 | [![Deploy with Vercel](https://zeit.co/button)](https://vercel.com/new/project?template=https://github.com/microlinkhq/analytics) 9 | 10 | > Microservice to retrieve your CloudFlare Analytics. 11 | 12 | It converts your CloudFlare Analytics information: 13 | 14 | ![](https://i.imgur.com/iH0vyim.png) 15 | 16 | Into something easy to consume for any UI as JSON payload, from anywhere. 17 | 18 | See on live at [analytics.microlink.io](https://analytics.microlink.io/). 19 | 20 | # Environment Variables 21 | 22 | ### ZONE_ID 23 | 24 | *Required*
25 | Type: `string` 26 | 27 | The zone identifier associated with your domain. 28 | 29 | ### X_AUTH_EMAIL 30 | 31 | *Required*
32 | Type: `string` 33 | 34 | The email associated with your CloudFlare account. 35 | 36 | ### X_AUTH_KEY 37 | 38 | *Required*
39 | Type: `string` 40 | 41 | The authorization token associated with your CloudFlare account. 42 | 43 | ### REQ_TIMEOUT 44 | 45 | Type: `number`
46 | Default: 8000 47 | 48 | It specifies how much time after to consider a request as timeout, in milliseconds. 49 | 50 | ### MAX_CACHE 51 | 52 | Type: `number`
53 | Default: 86400 (1d) 54 | 55 | It specifies how much time a response can be cached, in seconds. 56 | 57 | ## License 58 | 59 | **oss** © [microlink.io](https://microlink.io), released under the [MIT](https://github.com/microlinkhq/oss/blob/master/LICENSE.md) License.
60 | Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/microlinkhq/oss/contributors). 61 | 62 | > [microlink.io](https://microlink.io) · GitHub [microlink.io](https://github.com/microlinkhq) · Twitter [@microlinkhq](https://twitter.com/microlinkhq) 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "analytics", 3 | "description": "Microservice to retrieve your CloudFlare Analytics.", 4 | "homepage": "https://github.com/microlinkhq/analytics", 5 | "version": "0.0.0", 6 | "main": "src/index.js", 7 | "author": { 8 | "email": "josefrancisco.verdu@gmail.com", 9 | "name": "Kiko Beats", 10 | "url": "https://kikobeats.com" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/microlinkhq/analytics.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/microlinkhq/analytics/issues" 18 | }, 19 | "keywords": [ 20 | "analytics", 21 | "microlink", 22 | "requests" 23 | ], 24 | "dependencies": { 25 | "calc-percent": "~1.0.1", 26 | "date-fns": "~4.1.0", 27 | "debug-logfmt": "~1.2.0", 28 | "got": "~11.8.6", 29 | "human-number": "~2.0.4", 30 | "p-reflect": "~2.1.0", 31 | "p-timeout": "~4.1.0", 32 | "pretty-bytes": "~5.6.0" 33 | }, 34 | "devDependencies": { 35 | "@commitlint/cli": "latest", 36 | "@commitlint/config-conventional": "latest", 37 | "@ksmithut/prettier-standard": "latest", 38 | "ava": "latest", 39 | "finepack": "latest", 40 | "nano-staged": "latest", 41 | "simple-git-hooks": "latest", 42 | "standard": "latest", 43 | "standard-markdown": "latest" 44 | }, 45 | "engines": { 46 | "node": ">= 12" 47 | }, 48 | "files": [ 49 | "src" 50 | ], 51 | "scripts": { 52 | "build": "untracked > .nowignore", 53 | "clean": "rm -rf node_modules", 54 | "dev": "TZ=UTC NODE_ENV=development DEBUG=analytics* node src/server.js", 55 | "lint": "standard-markdown README.md && standard", 56 | "pretest": "npm run lint", 57 | "start": "TZ=UTC NODE_ENV=production DEBUG=analytics* node index.js", 58 | "test": "ava" 59 | }, 60 | "private": true, 61 | "license": "MIT", 62 | "commitlint": { 63 | "extends": [ 64 | "@commitlint/config-conventional" 65 | ] 66 | }, 67 | "nano-staged": { 68 | "*.js": [ 69 | "prettier-standard", 70 | "standard --fix" 71 | ], 72 | "*.md": [ 73 | "standard-markdown" 74 | ], 75 | "package.json": [ 76 | "finepack" 77 | ] 78 | }, 79 | "simple-git-hooks": { 80 | "commit-msg": "npx commitlint --edit", 81 | "pre-commit": "npx nano-staged" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/analytics.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* eslint camelcase: "off" */ 4 | 5 | const { differenceInDays } = require('date-fns/differenceInDays') 6 | const { getQuarter } = require('date-fns/getQuarter') 7 | const { subMonths } = require('date-fns/subMonths') 8 | const { parseISO } = require('date-fns/parseISO') 9 | const { getYear } = require('date-fns/getYear') 10 | const { format } = require('date-fns/format') 11 | const calcPercent = require('calc-percent') 12 | const humanNumber = require('human-number') 13 | const prettyBytes = require('pretty-bytes') 14 | const got = require('got') 15 | 16 | const { ZONE_ID, X_AUTH_EMAIL, X_AUTH_KEY, HISTORY_MONTHS = 3 } = process.env 17 | 18 | const prettyReq = value => 19 | humanNumber(value, n => Number.parseFloat(n).toFixed(0)) 20 | 21 | const addReqs = (item1, item2) => { 22 | if (!item1) return item2 23 | if (!item2) return item1 24 | 25 | return toReqs({ 26 | cachedRequests: item1.cached_reqs + item2.cached_reqs, 27 | requests: item1.reqs + item2.reqs 28 | }) 29 | } 30 | 31 | const toReqs = ({ requests: uncached_reqs, cachedRequests: cached_reqs }) => { 32 | const reqs = cached_reqs + uncached_reqs 33 | 34 | return { 35 | reqs, 36 | reqs_pretty: prettyReq(reqs), 37 | cached_reqs, 38 | cached_reqs_pretty: prettyReq(cached_reqs), 39 | uncached_reqs, 40 | uncached_reqs_pretty: prettyReq(uncached_reqs), 41 | cached_reqs_percentage: calcPercent(cached_reqs, reqs, { suffix: '%' }) 42 | } 43 | } 44 | 45 | const addBytes = (item1, item2) => { 46 | if (!item1) return item2 47 | if (!item2) return item1 48 | 49 | return toBytes({ 50 | cachedBytes: item1.cached_bytes + item2.cached_bytes, 51 | bytes: item1.bytes + item2.bytes 52 | }) 53 | } 54 | 55 | const toBytes = ({ cachedBytes: cached_bytes, bytes: uncached_bytes }) => { 56 | const bytes = cached_bytes + uncached_bytes 57 | 58 | return { 59 | bytes, 60 | bytes_pretty: prettyBytes(bytes), 61 | cached_bytes, 62 | cached_bytes_pretty: prettyBytes(cached_bytes), 63 | uncached_bytes, 64 | uncached_bytes_pretty: prettyBytes(uncached_bytes), 65 | cached_bytes_percentage: calcPercent(cached_bytes, bytes, { suffix: '%' }) 66 | } 67 | } 68 | 69 | const getMonthKey = rawKey => { 70 | const key = rawKey.split('-') 71 | key.pop() 72 | return key.join('-') 73 | } 74 | 75 | const getQuarterKey = key => 76 | `Q${getQuarter(parseISO(key))}-${getYear(parseISO(key))}` 77 | 78 | module.exports = async () => { 79 | const now = new Date() 80 | const timestamp = subMonths(now, HISTORY_MONTHS) 81 | const date = format(timestamp, 'yyyy-MM-dd') 82 | const limit = differenceInDays(now, timestamp) 83 | 84 | const query = `{ 85 | viewer { 86 | zones(filter: {zoneTag: "${ZONE_ID}"}) { 87 | httpRequests1dGroups(orderBy:[date_DESC] limit: ${limit}, filter: { date_gt: "${date}" }) { 88 | dimensions { 89 | date 90 | } 91 | sum { 92 | requests 93 | cachedRequests 94 | bytes 95 | cachedBytes 96 | } 97 | } 98 | } 99 | } 100 | }` 101 | 102 | const body = await got('https://api.cloudflare.com/client/v4/graphql', { 103 | method: 'POST', 104 | body: JSON.stringify({ query }), 105 | headers: { 106 | 'x-auth-email': X_AUTH_EMAIL, 107 | 'x-auth-key': X_AUTH_KEY 108 | } 109 | }).json() 110 | 111 | const [{ httpRequests1dGroups }] = body.data.viewer.zones 112 | 113 | const byDay = httpRequests1dGroups.reduce((acc, item, index) => { 114 | const key = item.dimensions.date 115 | return { ...acc, [key]: { ...toReqs(item.sum), ...toBytes(item.sum) } } 116 | }, {}) 117 | 118 | const byMonth = Object.keys(byDay).reduce((acc, key) => { 119 | const monthKey = getMonthKey(key) 120 | acc[monthKey] = { 121 | ...addReqs(acc[monthKey], byDay[key]), 122 | ...addBytes(acc[monthKey], byDay[key]) 123 | } 124 | return acc 125 | }, {}) 126 | 127 | const byQuarter = Object.keys(byDay).reduce((acc, key) => { 128 | const quarterKey = getQuarterKey(key) 129 | 130 | acc[quarterKey] = { 131 | ...addReqs(acc[quarterKey], byDay[key]), 132 | ...addBytes(acc[quarterKey], byDay[key]) 133 | } 134 | 135 | return acc 136 | }, {}) 137 | 138 | return { 139 | byQuarter, 140 | byMonth, 141 | byDay 142 | } 143 | } 144 | 145 | module.exports.getMonthKey = getMonthKey 146 | module.exports.getQuarterKey = getQuarterKey 147 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug-logfmt')('analytics') 4 | const pReflect = require('p-reflect') 5 | const pTimeout = require('p-timeout') 6 | 7 | const ONE_DAY_SECONDS = 86400 8 | const { MAX_CACHE = ONE_DAY_SECONDS, REQ_TIMEOUT = 8000 } = process.env 9 | const analytics = require('./analytics') 10 | 11 | let CACHE = null 12 | 13 | const isEmpty = input => input == null || Object.keys(input).length === 0 14 | 15 | module.exports = async (req, res) => { 16 | res.setHeader('Access-Control-Allow-Origin', '*') 17 | 18 | const { isFulfilled, value, reason } = await pReflect( 19 | pTimeout(analytics(), REQ_TIMEOUT) 20 | ) 21 | 22 | if (isFulfilled && !isEmpty(value)) CACHE = value 23 | 24 | if (!isEmpty(CACHE)) { 25 | res.setHeader('Content-Type', 'application/json; charset=utf-8') 26 | res.setHeader( 27 | 'Cache-Control', 28 | `public, must-revalidate, max-age=${MAX_CACHE}` 29 | ) 30 | const data = JSON.stringify(CACHE) 31 | res.setHeader('Content-Length', Buffer.byteLength(data)) 32 | return res.end(data) 33 | } 34 | 35 | debug.error(reason.stack || reason) 36 | res.statusCode = 400 37 | res.end() 38 | } 39 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug-logfmt')('analytics') 4 | 5 | const server = require('http').createServer(require('.')) 6 | 7 | const port = process.env.PORT || process.env.port || 3000 8 | 9 | server.on('error', error => { 10 | debug({ status: 'error', message: error.message, trace: error.stack }) 11 | process.exit(1) 12 | }) 13 | 14 | server.listen(port, async () => { 15 | const { address, port } = server.address() 16 | debug({ 17 | status: 'listening', 18 | pid: process.pid, 19 | address: `${address}:${port}` 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | 5 | const { getMonthKey, getQuarterKey } = require('../src/analytics') 6 | 7 | test('.getMonthKey', t => { 8 | t.is(getMonthKey('2023-06-09'), '2023-06') 9 | }) 10 | 11 | test('.getQuarterKey', t => { 12 | t.is(getQuarterKey('2023-01-09'), 'Q1-2023') 13 | t.is(getQuarterKey('2023-02-09'), 'Q1-2023') 14 | t.is(getQuarterKey('2023-03-09'), 'Q1-2023') 15 | t.is(getQuarterKey('2023-04-09'), 'Q2-2023') 16 | t.is(getQuarterKey('2023-05-09'), 'Q2-2023') 17 | t.is(getQuarterKey('2023-06-09'), 'Q2-2023') 18 | t.is(getQuarterKey('2023-07-09'), 'Q3-2023') 19 | t.is(getQuarterKey('2023-08-09'), 'Q3-2023') 20 | t.is(getQuarterKey('2023-09-09'), 'Q3-2023') 21 | t.is(getQuarterKey('2023-10-09'), 'Q4-2023') 22 | t.is(getQuarterKey('2023-11-09'), 'Q4-2023') 23 | t.is(getQuarterKey('2023-12-09'), 'Q4-2023') 24 | }) 25 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "src/index.js", 6 | "use": "@vercel/node@canary" 7 | } 8 | ], 9 | "env": { 10 | "NODE_ENV": "production", 11 | "DEBUG": "analytics*" 12 | }, 13 | "routes": [ 14 | { 15 | "src": "/robots.txt", 16 | "status": 204 17 | }, 18 | { 19 | "src": "/favicon.ico", 20 | "status": 204 21 | }, 22 | { 23 | "src": "/(.*)", 24 | "dest": "/src/index.js", 25 | "headers": { 26 | "Access-Control-Allow-Origin": "*" 27 | } 28 | } 29 | ] 30 | } 31 | --------------------------------------------------------------------------------