├── src ├── version.ts ├── index.ts ├── types.ts ├── scripts.ts └── analytics.ts ├── bun.lockb ├── .github ├── dependabot.yml └── workflows │ └── release.yml ├── tsup.config.js ├── .gitignore ├── rome.json ├── tsconfig.json ├── cmd └── set-version.js ├── package.json └── README.md /src/version.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = "0.1.0"; 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/core-analytics/main/bun.lockb -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./analytics"; 2 | export { Aggregate } from "./types"; -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "." 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | format: ["cjs", "esm"], 6 | sourcemap: false, 7 | clean: true, 8 | dts: true, 9 | minify: true, 10 | }); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env* 28 | !.env.example 29 | 30 | # turbo 31 | .turbo 32 | .vercel 33 | 34 | dist 35 | 36 | debug.ts -------------------------------------------------------------------------------- /rome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/rome/configuration_schema.json", 3 | "linter": { 4 | "enabled": true, 5 | "rules": { 6 | "recommended": true 7 | }, 8 | "ignore": [ 9 | "node_modules", 10 | ".next", 11 | "dist", 12 | ".turbo" 13 | ] 14 | }, 15 | "formatter": { 16 | "enabled": true, 17 | "lineWidth": 120, 18 | "indentStyle": "space", 19 | "ignore": [ 20 | "node_modules", 21 | ".next", 22 | "dist", 23 | ".turbo" 24 | ] 25 | } 26 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "bundler", 7 | "moduleDetection": "force", 8 | "allowImportingTsExtensions": true, 9 | "noEmit": true, 10 | "strict": true, 11 | "downlevelIteration": true, 12 | "skipLibCheck": true, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "allowJs": true, 16 | "types": [ 17 | "bun-types" // add Bun global 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cmd/set-version.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const root = process.argv[2]; // path to project root 5 | const version = process.argv[3].replace(/^v/, ""); // new version 6 | 7 | console.log(`Updating version=${version} in ${root}`); 8 | 9 | const content = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf-8")); 10 | 11 | content.version = version; 12 | 13 | fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(content, null, 2)); 14 | fs.writeFileSync(path.join(root, "src", "version.ts"), `export const VERSION = "${version}";`); 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upstash/core-analytics", 3 | "version": "0.1.0", 4 | "main": "./dist/index.js", 5 | "types": "./dist/index.d.ts", 6 | "license": "MIT", 7 | "private": false, 8 | "keywords": [ 9 | "analytics", 10 | "upstash", 11 | "serverless" 12 | ], 13 | "bugs": { 14 | "url": "https://github.com/upstash/core-analytics/issues" 15 | }, 16 | "homepage": "https://github.com/upstash/core-analytics#readme", 17 | "files": [ 18 | "./dist/**" 19 | ], 20 | "author": "Andreas Thomas ", 21 | "scripts": { 22 | "build": "tsup", 23 | "test": "jest --collect-coverage", 24 | "fmt": "bunx rome check . --apply-suggested && bunx rome format . --write" 25 | }, 26 | "devDependencies": { 27 | "rome": "^11.0.0", 28 | "typescript": "^5.0.0", 29 | "bun-types": "latest", 30 | "tsup": "latest" 31 | }, 32 | "dependencies": { 33 | "@upstash/redis": "^1.28.3" 34 | }, 35 | "engines": { 36 | "node": ">=16.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Set env 17 | run: echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 18 | 19 | - name: Setup Node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 18 23 | 24 | - name: Set package version 25 | run: echo $(jq --arg v "${{ env.VERSION }}" '(.version) = $v' package.json) > package.json 26 | 27 | - name: Install bun 28 | run: npm install -g bun 29 | 30 | - name: Install dependencies 31 | run: bun install 32 | 33 | - name: Build 34 | run: bun run build 35 | 36 | - name: Add npm token 37 | run: echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}}" > .npmrc 38 | 39 | - name: Publish release candidate 40 | if: "github.event.release.prerelease" 41 | run: npm publish --access public --tag=canary --no-git-checks 42 | 43 | - name: Publish 44 | if: "!github.event.release.prerelease" 45 | run: npm publish --access public --no-git-checks 46 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "@upstash/redis"; 2 | 3 | export type Event = { 4 | time?: number; 5 | [key: string]: string | number | boolean | undefined; 6 | }; 7 | 8 | export class Key { 9 | constructor(public readonly prefix: string, public readonly table: string, public readonly bucket: number) {} 10 | 11 | public toString() { 12 | return [this.prefix, this.table, this.bucket].join(":"); 13 | } 14 | static fromString(key: string) { 15 | const [prefix, table, bucket] = key.split(":"); 16 | return new Key(prefix, table, parseInt(bucket)); 17 | } 18 | } 19 | 20 | export type Window = `${number}${"s" | "m" | "h" | "d"}`; 21 | 22 | export type AnalyticsConfig = { 23 | redis: Redis; 24 | /** 25 | * Configure the bucket size for analytics. All events inside the window will be stored inside 26 | * the same bucket. This reduces the number of keys that need to be scanned when aggregating 27 | * and reduces your cost. 28 | * 29 | * Must be either a string in the format of `1s`, `2m`, `3h`, `4d` or a number of milliseconds. 30 | */ 31 | window: Window | number; 32 | prefix?: string; 33 | 34 | /** 35 | * Configure the retention period for analytics. All events older than the retention period will 36 | * be deleted. This reduces the number of keys that need to be scanned when aggregating. 37 | * 38 | * Can either be a string in the format of `1s`, `2m`, `3h`, `4d` or a number of milliseconds. 39 | * 0, negative or undefined means that the retention is disabled. 40 | * 41 | * @default Disabled 42 | * 43 | * Buckets are evicted when they are read, not when they are written. This is much cheaper since 44 | * it only requires a single command to ingest data. 45 | */ 46 | retention?: Window | number 47 | }; 48 | 49 | interface AggregateTime { 50 | time: number; 51 | } 52 | 53 | interface AggregateGeneric { 54 | [someFieldName: string]: { 55 | [someFieldValue: string]: number; 56 | }; 57 | } 58 | 59 | export type Aggregate = AggregateTime & AggregateGeneric; 60 | 61 | // value of the success field coming from lua 62 | // 1 represents true, null represents false 63 | export type RawSuccessResponse = 1 | null 64 | export type SuccessResponse = RawSuccessResponse | string 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

@upstash/core-analytics

3 |
Serverless Analytics for Redis
4 |
5 | 6 |
7 | 8 | This library offers some low level building blocks to record and analyze custom events in Redis. 9 | It's main purpose is to provide a simple way to record and query events in Redis without having to worry about the underlying data structure so we can build more advanced analytics features on top of it. 10 | 11 | The quickstart below is slightly outdated. We're working on it. 12 | 13 |
14 | 15 | ## Quickstart 16 | 17 | 1. Create a redis database 18 | 19 | Go to [console.upstash.com/redis](https://console.upstash.com/redis) and create 20 | a new global database. 21 | 22 | After creating the db, copy the `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` to your `.env` file. 23 | 24 | 3. Install `@upstash/core-analytics` in your project 25 | 26 | ```bash 27 | npm install @upstash/analytics @upstash/redis 28 | ``` 29 | 30 | 4. Create an analytics client 31 | 32 | ```ts 33 | import { Analytics } from "@upstash/core-analytics"; 34 | import { Redis } from "@upstash/redis"; 35 | 36 | const analytics = new Analytics({ 37 | redis: Redis.fromEnv(), 38 | window: "1d", 39 | }); 40 | ``` 41 | 42 | 5. Ingest some events 43 | 44 | An event consists of a `time` field and any additional key-value pairs that you can use to record any information you want. 45 | 46 | ```ts 47 | const event = { 48 | time: Date.now() // optional (default: Date.now()) 49 | userId: "chronark", 50 | page: "/auth/login", 51 | country: "DE", 52 | } 53 | 54 | await analytics.ingest("pageviews", event); 55 | ``` 56 | 57 | 4. Query your events 58 | 59 | ```ts 60 | const result = await analytics.query("pageviews"); 61 | ``` 62 | 63 | ## Development 64 | 65 | This project uses `bun` for dependency management. 66 | 67 | #### Install dependencies 68 | 69 | ```bash 70 | bun install 71 | ``` 72 | 73 | #### Build 74 | 75 | ```bash 76 | bun build 77 | ``` 78 | 79 | ## Database Schema 80 | 81 | All metrics are stored in Redis `Hash` data types and sharded into buckets based on the `window` option. 82 | 83 | ``` 84 | @upstash/analytics:{TABLE}:{TIMESTAMP} 85 | ``` 86 | 87 | - `TABLE` is a namespace to group events together. ie for managing multiple projects int a single database 88 | - `TIMESTAMP` is the starting timestamp of each window 89 | 90 | The field of each hash is a serialized JSON object with the user's event data and the value is the number of times this event has been recorded. 91 | 92 | ```json 93 | { 94 | "{\"page\": \"/auth/login\",\"country\": \"DE\"}": 5, 95 | "{\"page\": \"/auth/login\",\"country\": \"US\"}": 2 96 | } 97 | ``` 98 | -------------------------------------------------------------------------------- /src/scripts.ts: -------------------------------------------------------------------------------- 1 | export const aggregateHourScript = ` 2 | local key = KEYS[1] 3 | local field = ARGV[1] 4 | 5 | local data = redis.call("ZRANGE", key, 0, -1, "WITHSCORES") 6 | local count = {} 7 | 8 | for i = 1, #data, 2 do 9 | local json_str = data[i] 10 | local score = tonumber(data[i + 1]) 11 | local obj = cjson.decode(json_str) 12 | 13 | local fieldValue = obj[field] 14 | 15 | if count[fieldValue] == nil then 16 | count[fieldValue] = score 17 | else 18 | count[fieldValue] = count[fieldValue] + score 19 | end 20 | end 21 | 22 | local result = {} 23 | for k, v in pairs(count) do 24 | table.insert(result, {k, v}) 25 | end 26 | 27 | return result 28 | ` 29 | 30 | export const getMostAllowedBlockedScript = ` 31 | local prefix = KEYS[1] 32 | local first_timestamp = tonumber(ARGV[1]) -- First timestamp to check 33 | local increment = tonumber(ARGV[2]) -- Increment between each timestamp 34 | local num_timestamps = tonumber(ARGV[3]) -- Number of timestampts to check (24 for a day and 24 * 7 for a week) 35 | local num_elements = tonumber(ARGV[4]) -- Number of elements to fetch in each category 36 | local check_at_most = tonumber(ARGV[5]) -- Number of elements to check at most. 37 | 38 | local keys = {} 39 | for i = 1, num_timestamps do 40 | local timestamp = first_timestamp - (i - 1) * increment 41 | table.insert(keys, prefix .. ":" .. timestamp) 42 | end 43 | 44 | -- get the union of the groups 45 | local zunion_params = {"ZUNION", num_timestamps, unpack(keys)} 46 | table.insert(zunion_params, "WITHSCORES") 47 | local result = redis.call(unpack(zunion_params)) 48 | 49 | -- select num_elements many items 50 | local true_group = {} 51 | local false_group = {} 52 | local denied_group = {} 53 | local true_count = 0 54 | local false_count = 0 55 | local denied_count = 0 56 | local i = #result - 1 57 | 58 | -- index to stop at after going through "checkAtMost" many items: 59 | local cutoff_index = #result - 2 * check_at_most 60 | 61 | -- iterate over the results 62 | while (true_count + false_count + denied_count) < (num_elements * 3) and 1 <= i and i >= cutoff_index do 63 | local score = tonumber(result[i + 1]) 64 | if score > 0 then 65 | local element = result[i] 66 | if string.find(element, "success\\":true") and true_count < num_elements then 67 | table.insert(true_group, {score, element}) 68 | true_count = true_count + 1 69 | elseif string.find(element, "success\\":false") and false_count < num_elements then 70 | table.insert(false_group, {score, element}) 71 | false_count = false_count + 1 72 | elseif string.find(element, "success\\":\\"denied") and denied_count < num_elements then 73 | table.insert(denied_group, {score, element}) 74 | denied_count = denied_count + 1 75 | end 76 | end 77 | i = i - 2 78 | end 79 | 80 | return {true_group, false_group, denied_group} 81 | ` 82 | 83 | export const getAllowedBlockedScript = ` 84 | local prefix = KEYS[1] 85 | local first_timestamp = tonumber(ARGV[1]) 86 | local increment = tonumber(ARGV[2]) 87 | local num_timestamps = tonumber(ARGV[3]) 88 | 89 | local keys = {} 90 | for i = 1, num_timestamps do 91 | local timestamp = first_timestamp - (i - 1) * increment 92 | table.insert(keys, prefix .. ":" .. timestamp) 93 | end 94 | 95 | -- get the union of the groups 96 | local zunion_params = {"ZUNION", num_timestamps, unpack(keys)} 97 | table.insert(zunion_params, "WITHSCORES") 98 | local result = redis.call(unpack(zunion_params)) 99 | 100 | return result 101 | ` -------------------------------------------------------------------------------- /src/analytics.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "@upstash/redis"; 2 | import { 3 | Event, 4 | Window, 5 | AnalyticsConfig, 6 | Aggregate, 7 | SuccessResponse 8 | } from "./types" 9 | import { 10 | aggregateHourScript, 11 | getAllowedBlockedScript, 12 | getMostAllowedBlockedScript 13 | } from "./scripts"; 14 | 15 | export class Analytics { 16 | private readonly redis: Redis; 17 | private readonly prefix: string; 18 | private readonly bucketSize: number; 19 | 20 | constructor(config: AnalyticsConfig) { 21 | this.redis = config.redis; 22 | this.prefix = config.prefix ?? "@upstash/analytics"; 23 | this.bucketSize = this.parseWindow(config.window); 24 | } 25 | 26 | private validateTableName(table: string) { 27 | const regex = /^[a-zA-Z0-9_-]+$/; 28 | if (!regex.test(table)) { 29 | throw new Error( 30 | `Invalid table name: ${table}. Table names can only contain letters, numbers, dashes and underscores.`, 31 | ); 32 | } 33 | } 34 | 35 | /** 36 | * Parses the window string into a number of milliseconds 37 | */ 38 | private parseWindow(window: Window | number): number { 39 | if (typeof window === "number") { 40 | if (window <= 0) { 41 | throw new Error(`Invalid window: ${window}`); 42 | } 43 | return window; 44 | } 45 | const regex = /^(\d+)([smhd])$/; 46 | if (!regex.test(window)) { 47 | throw new Error(`Invalid window: ${window}`); 48 | } 49 | const [, valueStr, unit] = window.match(regex)!; 50 | const value = parseInt(valueStr); 51 | switch (unit) { 52 | case "s": 53 | return value * 1000; 54 | case "m": 55 | return value * 1000 * 60; 56 | case "h": 57 | return value * 1000 * 60 * 60; 58 | case "d": 59 | return value * 1000 * 60 * 60 * 24; 60 | default: 61 | throw new Error(`Invalid window unit: ${unit}`); 62 | } 63 | } 64 | 65 | public getBucket(time?: number): number { 66 | const bucketTime = time ?? Date.now(); 67 | // Bucket is a unix timestamp in milliseconds marking 68 | // the beginning of a window 69 | return Math.floor(bucketTime / this.bucketSize) * this.bucketSize; 70 | } 71 | 72 | /** 73 | * Ingest a new event 74 | * @param table 75 | * @param event 76 | */ 77 | public async ingest(table: string, ...events: Event[]): Promise { 78 | this.validateTableName(table); 79 | await Promise.all( 80 | events.map(async (event) => { 81 | const bucket = this.getBucket(event.time); 82 | const key = [this.prefix, table, bucket].join(":"); 83 | 84 | await this.redis.zincrby( 85 | key, 86 | 1, 87 | JSON.stringify({ 88 | ...event, 89 | time: undefined, 90 | }) 91 | ) 92 | }), 93 | ); 94 | } 95 | 96 | protected formatBucketAggregate( 97 | rawAggregate: [SuccessResponse, number][], 98 | groupBy: string, 99 | bucket: number 100 | ): Aggregate { 101 | const returnObject: { [key: string]: { [key: string]: number } } = {}; 102 | rawAggregate.forEach(([group, count]) => { 103 | if (groupBy == "success") { 104 | group = group === 1 105 | ? "true" 106 | : group === null 107 | ? "false" 108 | : group; // group can be "denyList" 109 | }; 110 | returnObject[groupBy] = returnObject[groupBy] || {}; 111 | returnObject[groupBy][(group ?? "null").toString()] = count; 112 | }); 113 | return {time: bucket, ...returnObject} as Aggregate; 114 | } 115 | 116 | public async aggregateBucket( 117 | table: string, 118 | groupBy: string, 119 | timestamp?: number, 120 | ): Promise { 121 | this.validateTableName(table); 122 | 123 | const bucket = this.getBucket(timestamp); 124 | const key = [this.prefix, table, bucket].join(":"); 125 | 126 | const result = await this.redis.eval( 127 | aggregateHourScript, 128 | [key], 129 | [groupBy] 130 | ) as [SuccessResponse, number][]; 131 | 132 | return this.formatBucketAggregate(result, groupBy, bucket) 133 | } 134 | 135 | public async aggregateBuckets( 136 | table: string, 137 | groupBy: string, 138 | bucketCount: number, 139 | timestamp?: number 140 | ): Promise { 141 | this.validateTableName(table); 142 | 143 | let bucket = this.getBucket(timestamp) 144 | const promises = [] 145 | 146 | for (let i = 0; i < bucketCount; i += 1) { 147 | promises.push( 148 | this.aggregateBucket(table, groupBy, bucket) 149 | ) 150 | bucket = bucket - this.bucketSize 151 | } 152 | 153 | return Promise.all(promises) 154 | } 155 | 156 | public async aggregateBucketsWithPipeline( 157 | table: string, 158 | groupBy: string, 159 | bucketCount: number, 160 | timestamp?: number, 161 | maxPipelineSize?: number 162 | ): Promise { 163 | this.validateTableName(table); 164 | 165 | maxPipelineSize = maxPipelineSize ?? 48 166 | let bucket = this.getBucket(timestamp); 167 | const buckets: number[] = [] 168 | let pipeline = this.redis.pipeline(); 169 | 170 | const pipelinePromises: Promise<[SuccessResponse, number][][]>[] = [] 171 | for (let i = 1; i <= bucketCount; i += 1) { 172 | const key = [this.prefix, table, bucket].join(":"); 173 | pipeline.eval( 174 | aggregateHourScript, 175 | [key], 176 | [groupBy] 177 | ); 178 | buckets.push(bucket) 179 | bucket = bucket - this.bucketSize; 180 | 181 | if (i % maxPipelineSize == 0 || i == bucketCount) { 182 | pipelinePromises.push(pipeline.exec<[string, number][][]>()) 183 | pipeline = this.redis.pipeline() 184 | } 185 | } 186 | const bucketResults = (await Promise.all(pipelinePromises)).flat() 187 | 188 | return bucketResults.map((result, index) => { 189 | return this.formatBucketAggregate( 190 | result, 191 | groupBy, 192 | buckets[index] 193 | ) 194 | }) 195 | } 196 | 197 | public async getAllowedBlocked( 198 | table: string, 199 | timestampCount: number, 200 | timestamp?: number, 201 | ): Promise< 202 | Record 203 | > { 204 | this.validateTableName(table); 205 | 206 | const key = [this.prefix, table].join(":"); 207 | const bucket = this.getBucket(timestamp) 208 | 209 | const result = await this.redis.eval( 210 | getAllowedBlockedScript, 211 | [key], 212 | [bucket, this.bucketSize, timestampCount] 213 | ) as (string | {identifier: string, success: boolean})[] 214 | 215 | 216 | const allowedBlocked: Record = {} 217 | 218 | for (let i = 0; i < result.length; i += 2) { 219 | const info = result[i] as {identifier: string, success: boolean} 220 | const identifier: string = info.identifier; 221 | const count = +result[i + 1] // cast string to number; 222 | 223 | if (!allowedBlocked[identifier]) { 224 | allowedBlocked[identifier] = {"success":0, "blocked":0} 225 | } 226 | allowedBlocked[identifier][info.success ? "success" : "blocked"] = count 227 | } 228 | 229 | return allowedBlocked 230 | } 231 | 232 | /** 233 | * Fetches the most allowed & blocked and denied items. 234 | * 235 | * @param table Ratelimit prefix to search for analytics 236 | * @param timestampCount Number of timestamps (24 for a day and 24 * 7 for a week) 237 | * @param itemCount Number of items to fetch from each category. If set to 30, 238 | * 30 items will be fetched from each category. 90 items will be 239 | * returned in total if there are enough items in each category. 240 | * @param timestamp Most recent bucket timestamp to read from 241 | * @param checkAtMost Early finish parameter. Imagine that itemCount is set to 30. 242 | * If checkAtMost is set to 100, script will stop after checking 243 | * 100 items even if there aren't 90 items yet. 244 | * Set to `itemCount * 5` by default. 245 | * @returns most allowed & blocked and denied items 246 | */ 247 | public async getMostAllowedBlocked( 248 | table: string, 249 | timestampCount: number, 250 | itemCount: number, 251 | timestamp?: number, 252 | checkAtMost?: number 253 | ): Promise< 254 | { 255 | allowed: {identifier: string, count: number}[], 256 | ratelimited: {identifier: string, count: number}[] 257 | denied: {identifier: string, count: number}[] 258 | } 259 | > { 260 | this.validateTableName(table); 261 | 262 | const key = [this.prefix, table].join(":"); 263 | const bucket = this.getBucket(timestamp) 264 | 265 | const checkAtMostValue = checkAtMost ?? itemCount * 5 266 | 267 | const [allowed, ratelimited, denied] = await this.redis.eval( 268 | getMostAllowedBlockedScript, 269 | [key], 270 | [bucket, this.bucketSize, timestampCount, itemCount, checkAtMostValue] 271 | ) as [string, {identifier: string, success: boolean}][][] 272 | 273 | return { 274 | allowed: this.toDicts(allowed), 275 | ratelimited: this.toDicts(ratelimited), 276 | denied: this.toDicts(denied) 277 | } 278 | } 279 | 280 | /** 281 | * convert ["a", 1, ...] to [{identifier: 1, count: 1}, ...] 282 | * @param array 283 | */ 284 | protected toDicts (array: [string, {identifier: string, success: boolean}][]) { 285 | const dict: {identifier: string, count: number}[] = []; 286 | for (let i = 0; i < array.length; i += 1) { 287 | const count = +array[i][0] // cast string to number; 288 | const info = array[i][1] 289 | dict.push({ 290 | identifier: info.identifier, 291 | count: count 292 | }) 293 | } 294 | return dict; 295 | } 296 | } 297 | --------------------------------------------------------------------------------