hirefire-resource 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/package.json +64 -0
- package/src/configuration.js +20 -0
- package/src/errors.js +23 -0
- package/src/hirefire.js +13 -0
- package/src/index.js +3 -0
- package/src/macro/bullmq.js +110 -0
- package/src/middleware/connect.js +20 -0
- package/src/middleware/express.js +19 -0
- package/src/middleware/koa.js +21 -0
- package/src/middleware.js +69 -0
- package/src/utility.js +22 -0
- package/src/version.js +3 -0
- package/src/web.js +216 -0
- package/src/worker.js +45 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
No changes since release yet.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 HireFire
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
## HireFire Integration Library for Node.js Applications
|
|
2
|
+
|
|
3
|
+
This library integrates Node.js applications with HireFire's Dyno Managers (Heroku Dyno Autoscalers). Instructions specific to supported web frameworks and worker libraries are provided during the setup process.
|
|
4
|
+
|
|
5
|
+
**Supported web frameworks:**
|
|
6
|
+
|
|
7
|
+
- Express
|
|
8
|
+
- Koa
|
|
9
|
+
- Connect
|
|
10
|
+
- Sails
|
|
11
|
+
- Nest
|
|
12
|
+
|
|
13
|
+
**Supported worker libraries:**
|
|
14
|
+
|
|
15
|
+
- BullMQ
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
Since 2011, over 1,000 companies have trusted [HireFire] to autoscale more than 5,000 applications hosted on [Heroku], managing over 10,000 web and worker dynos.
|
|
20
|
+
|
|
21
|
+
HireFire is distinguished by its support for both web and worker dynos, extending autoscaling capabilities to Standard-tier dynos. It provides fine-grained control over scaling behavior and improves scaling accuracy by monitoring more reliable metrics at the application level. These metrics include request queue time (web), job queue latency (worker), and job queue size (worker), which contribute to making more effective scaling decisions.
|
|
22
|
+
|
|
23
|
+
For more information, visit our [home page][HireFire].
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
- Run `bin/setup` to prepare the environment.
|
|
30
|
+
- See `npm run` for common tasks.
|
|
31
|
+
|
|
32
|
+
## Release
|
|
33
|
+
|
|
34
|
+
1. Update the version in `package.json` using `npm version <patch|minor|major>`.
|
|
35
|
+
2. Ensure that `CHANGELOG.md` is up-to-date.
|
|
36
|
+
3. Commit changes with `git commit`.
|
|
37
|
+
4. Create a `git tag` matching the new version (e.g., `v1.0.0`).
|
|
38
|
+
5. Push the new git tag. Continuous Integration will handle the distribution process.
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
This package is licensed under the terms of the MIT license.
|
|
43
|
+
|
|
44
|
+
[HireFire]: https://hirefire.io/
|
|
45
|
+
[Heroku]: https://heroku.com/
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hirefire-resource",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "HireFire integration library for Node.js applications",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"hirefire",
|
|
7
|
+
"heroku",
|
|
8
|
+
"autoscale",
|
|
9
|
+
"autoscaling",
|
|
10
|
+
"autoscaler",
|
|
11
|
+
"web",
|
|
12
|
+
"worker",
|
|
13
|
+
"dyno"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://hirefire.io",
|
|
16
|
+
"author": "Michael van Rooijen <support@hirefire.io>",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"files": [
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.js",
|
|
24
|
+
"./middleware/connect": "./src/middleware/connect.js",
|
|
25
|
+
"./middleware/express": "./src/middleware/express.js",
|
|
26
|
+
"./middleware/koa": "./src/middleware/koa.js",
|
|
27
|
+
"./macro/bullmq": "./src/macro/bullmq.js"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/hirefire/hirefire-resource-node.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/hirefire/hirefire-resource-node/issues"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "jest --silent --coverage --coverageReporters=html",
|
|
38
|
+
"check": "prettier --check --no-semi .",
|
|
39
|
+
"format": "prettier --write --no-semi .",
|
|
40
|
+
"doc": "jsdoc"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=16.0.0 <22.0.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"async-mutex": "<1"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@nestjs/common": "^10.2.8",
|
|
50
|
+
"@nestjs/platform-express": "^10.2.8",
|
|
51
|
+
"@nestjs/testing": "^10.2.8",
|
|
52
|
+
"bullmq": "^4.13.2",
|
|
53
|
+
"connect": "^3.7.0",
|
|
54
|
+
"express": "^4.18.2",
|
|
55
|
+
"jest": "^29.3.1",
|
|
56
|
+
"jsdoc": "^4.0.2",
|
|
57
|
+
"koa": "^2.14.2",
|
|
58
|
+
"nock": "^13.3.0",
|
|
59
|
+
"prettier": "^3.1.0",
|
|
60
|
+
"sails": "^1.5.8",
|
|
61
|
+
"sinon": "^17.0.1",
|
|
62
|
+
"supertest": "^6.3.3"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const { Web } = require("./web")
|
|
2
|
+
const { Worker } = require("./worker")
|
|
3
|
+
|
|
4
|
+
class Configuration {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.web = null
|
|
7
|
+
this.workers = []
|
|
8
|
+
this.logger = console
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
dyno(name, fn) {
|
|
12
|
+
if (name === "web") {
|
|
13
|
+
this.web = new Web(this)
|
|
14
|
+
} else {
|
|
15
|
+
this.workers.push(new Worker(name, fn))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = Configuration
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class MissingQueueError extends Error {
|
|
2
|
+
constructor() {
|
|
3
|
+
super("No queue was specified. Please specify at least one queue.")
|
|
4
|
+
this.name = "MissingQueueError"
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class JobQueueLatencyUnsupportedError extends Error {
|
|
9
|
+
constructor(name) {
|
|
10
|
+
super(`${name} currently does not support job queue latency measurements.`)
|
|
11
|
+
this.name = "JobQueueLatencyUnsupportedError"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function jobQueueLatencyUnsupported(name) {
|
|
16
|
+
throw new JobQueueLatencyUnsupportedError(name)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
MissingQueueError,
|
|
21
|
+
JobQueueLatencyUnsupportedError,
|
|
22
|
+
jobQueueLatencyUnsupported,
|
|
23
|
+
}
|
package/src/hirefire.js
ADDED
package/src/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const IORedis = require("ioredis")
|
|
2
|
+
const { unpack } = require("../utility")
|
|
3
|
+
const { jobQueueLatencyUnsupported } = require("../errors")
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Measures job queue latency. Currently, this functionality is not supported for BullMQ.
|
|
7
|
+
*
|
|
8
|
+
* @async
|
|
9
|
+
* @param {...any} args - Any number of arguments (ignored in function).
|
|
10
|
+
* @throws {JobQueueLatencyUnsupportedError} - Indicates that the module does not support job queue latency measurements.
|
|
11
|
+
* @returns {Promise<void>} - The function is asynchronous, but its return value is not used.
|
|
12
|
+
*/
|
|
13
|
+
async function jobQueueLatency(...args) {
|
|
14
|
+
jobQueueLatencyUnsupported("BullMQ")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calculates the total job queue size across specified queues.
|
|
19
|
+
*
|
|
20
|
+
* @async
|
|
21
|
+
* @param {...string} queues - Names of the queues for size measurement.
|
|
22
|
+
* @param {object} [options] - Optional options object. The options object can include a
|
|
23
|
+
* `connection` property, which is passed to IORedis and is compatible
|
|
24
|
+
* with its connection options. If no connection is provided, the
|
|
25
|
+
* function will use the value of the `REDIS_TLS_URL`, `REDIS_URL`,
|
|
26
|
+
* `REDISTOGO_URL`, `REDISCLOUD_URL`, `OPENREDIS_URL` environment
|
|
27
|
+
* variables, in the order specified. If none of these environment
|
|
28
|
+
* variables are set, it defaults to `redis://localhost:6379/0`.
|
|
29
|
+
* @returns {Promise<number>} Cumulative job queue size across the specified queues.
|
|
30
|
+
* @example
|
|
31
|
+
* // Calculate size across all queues
|
|
32
|
+
* await jobQueueSize()
|
|
33
|
+
* @example
|
|
34
|
+
* // Calculate size for the "default" queue
|
|
35
|
+
* await jobQueueSize("default")
|
|
36
|
+
* @example
|
|
37
|
+
* // Calculate size across "default" and "mailer" queues
|
|
38
|
+
* await jobQueueSize("default", "mailer")
|
|
39
|
+
* @example
|
|
40
|
+
* // Calculate Size using the options.connection property
|
|
41
|
+
* await jobQueueSize("default", { connection: "redis://localhost:6379/0" })
|
|
42
|
+
*/
|
|
43
|
+
async function jobQueueSize(...args) {
|
|
44
|
+
let { queues, options } = unpack(args)
|
|
45
|
+
|
|
46
|
+
const redis = new IORedis(
|
|
47
|
+
options.connection ||
|
|
48
|
+
process.env.REDIS_TLS_URL ||
|
|
49
|
+
process.env.REDIS_URL ||
|
|
50
|
+
process.env.REDISTOGO_URL ||
|
|
51
|
+
process.env.REDISCLOUD_URL ||
|
|
52
|
+
process.env.OPENREDIS_URL ||
|
|
53
|
+
"redis://localhost:6379/0",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if (queues.length === 0) {
|
|
57
|
+
const pipeline = redis.pipeline()
|
|
58
|
+
pipeline.keys("bull:*:wait")
|
|
59
|
+
pipeline.keys("bull:*:active")
|
|
60
|
+
pipeline.keys("bull:*:delayed")
|
|
61
|
+
|
|
62
|
+
const results = await pipeline.exec()
|
|
63
|
+
const keys = results.flatMap(([err, result]) => result || [])
|
|
64
|
+
const uniqueQueueNames = new Set()
|
|
65
|
+
|
|
66
|
+
keys.forEach((key) => {
|
|
67
|
+
const match = key.match(/^bull:(.*):(wait|active|delayed)$/)
|
|
68
|
+
if (match) {
|
|
69
|
+
uniqueQueueNames.add(match[1])
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
queues = Array.from(uniqueQueueNames)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let totalCount = 0
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const pipeline = redis.pipeline()
|
|
80
|
+
const now = Date.now() * 0x1000 // Match BullMQ's delayed job timestamp score encoding.
|
|
81
|
+
|
|
82
|
+
for (const queue of queues) {
|
|
83
|
+
pipeline.lindex(`bull:${queue}:wait`, -1)
|
|
84
|
+
pipeline.llen(`bull:${queue}:wait`)
|
|
85
|
+
pipeline.llen(`bull:${queue}:active`)
|
|
86
|
+
pipeline.zcount(`bull:${queue}:delayed`, "-inf", now)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const results = await pipeline.exec()
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < results.length; i += 4) {
|
|
92
|
+
const lastWaitJob = results[i][1]
|
|
93
|
+
const waitCount = results[i + 1][1] || 0
|
|
94
|
+
const activeCount = results[i + 2][1] || 0
|
|
95
|
+
const delayedCount = results[i + 3][1] || 0
|
|
96
|
+
|
|
97
|
+
totalCount += waitCount + activeCount + delayedCount
|
|
98
|
+
|
|
99
|
+
if (lastWaitJob && lastWaitJob.startsWith("0:")) {
|
|
100
|
+
totalCount -= 1
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
await redis.quit()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return totalCount
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { jobQueueLatency, jobQueueSize }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const { RequestInfo, request } = require("../middleware")
|
|
2
|
+
|
|
3
|
+
async function HireFireMiddlewareConnect(req, res, next) {
|
|
4
|
+
const response = await request(
|
|
5
|
+
new RequestInfo(
|
|
6
|
+
req.url,
|
|
7
|
+
req.headers["x-request-start"],
|
|
8
|
+
req.headers["hirefire-token"],
|
|
9
|
+
),
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
if (response) {
|
|
13
|
+
res.writeHead(response.status, response.headers)
|
|
14
|
+
res.end(JSON.stringify(response.body))
|
|
15
|
+
} else {
|
|
16
|
+
next()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = HireFireMiddlewareConnect
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { RequestInfo, request } = require("../middleware")
|
|
2
|
+
|
|
3
|
+
async function HireFireMiddlewareExpress(req, res, next) {
|
|
4
|
+
const response = await request(
|
|
5
|
+
new RequestInfo(
|
|
6
|
+
req.path,
|
|
7
|
+
req.get("X-Request-Start"),
|
|
8
|
+
req.get("HireFire-Token"),
|
|
9
|
+
),
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
if (response) {
|
|
13
|
+
res.status(response.status).set(response.headers).json(response.body)
|
|
14
|
+
} else {
|
|
15
|
+
next()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = HireFireMiddlewareExpress
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { RequestInfo, request } = require("../middleware")
|
|
2
|
+
|
|
3
|
+
async function HireFireMiddlewareKoa(ctx, next) {
|
|
4
|
+
const response = await request(
|
|
5
|
+
new RequestInfo(
|
|
6
|
+
ctx.path,
|
|
7
|
+
ctx.get("X-Request-Start"),
|
|
8
|
+
ctx.get("HireFire-Token"),
|
|
9
|
+
),
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
if (response) {
|
|
13
|
+
ctx.status = response.status
|
|
14
|
+
ctx.set(response.headers)
|
|
15
|
+
ctx.body = response.body
|
|
16
|
+
} else {
|
|
17
|
+
await next()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = HireFireMiddlewareKoa
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const HireFire = require(".")
|
|
2
|
+
const VERSION = require("./version")
|
|
3
|
+
|
|
4
|
+
class RequestInfo {
|
|
5
|
+
constructor(path, requestStartTime = null, token = null) {
|
|
6
|
+
this.path = path
|
|
7
|
+
this.requestStartTime = requestStartTime
|
|
8
|
+
? parseInt(requestStartTime, 10)
|
|
9
|
+
: null
|
|
10
|
+
this.token = token
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function request(requestInfo) {
|
|
15
|
+
await processRequestQueueTime(requestInfo)
|
|
16
|
+
|
|
17
|
+
if (matchesHireFirePath(requestInfo) || matchesInfoPath(requestInfo)) {
|
|
18
|
+
return {
|
|
19
|
+
status: 200,
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
"Cache-Control": "must-revalidate, private, max-age=0",
|
|
23
|
+
"HireFire-Resource": `Node-${VERSION}`,
|
|
24
|
+
},
|
|
25
|
+
body: await Promise.all(
|
|
26
|
+
HireFire.configuration.workers.map(async (worker) => ({
|
|
27
|
+
name: worker.name,
|
|
28
|
+
value: await worker.value(),
|
|
29
|
+
})),
|
|
30
|
+
),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function matchesHireFirePath(requestInfo) {
|
|
38
|
+
return (
|
|
39
|
+
process.env.HIREFIRE_TOKEN &&
|
|
40
|
+
requestInfo.path === "/hirefire" &&
|
|
41
|
+
requestInfo.token === process.env.HIREFIRE_TOKEN
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchesInfoPath(requestInfo) {
|
|
46
|
+
return (
|
|
47
|
+
process.env.HIREFIRE_TOKEN &&
|
|
48
|
+
requestInfo.path === `/hirefire/${process.env.HIREFIRE_TOKEN}/info`
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function processRequestQueueTime(requestInfo) {
|
|
53
|
+
if (
|
|
54
|
+
process.env.HIREFIRE_TOKEN &&
|
|
55
|
+
HireFire.configuration.web &&
|
|
56
|
+
requestInfo.requestStartTime
|
|
57
|
+
) {
|
|
58
|
+
await HireFire.configuration.web.startDispatcher()
|
|
59
|
+
await HireFire.configuration.web.addToBuffer(
|
|
60
|
+
calculateRequestQueueTime(requestInfo),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function calculateRequestQueueTime(requestInfo) {
|
|
66
|
+
return Math.max(Date.now() - requestInfo.requestStartTime, 0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { RequestInfo, request }
|
package/src/utility.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function unpack(args) {
|
|
2
|
+
const lastArg = args[args.length - 1]
|
|
3
|
+
let queues = []
|
|
4
|
+
let options = {}
|
|
5
|
+
|
|
6
|
+
if (
|
|
7
|
+
typeof lastArg === "object" &&
|
|
8
|
+
lastArg !== null &&
|
|
9
|
+
!Array.isArray(lastArg)
|
|
10
|
+
) {
|
|
11
|
+
queues = args.slice(0, -1)
|
|
12
|
+
options = lastArg
|
|
13
|
+
} else {
|
|
14
|
+
queues = args
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
queues = queues.flat()
|
|
18
|
+
|
|
19
|
+
return { queues, options }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { unpack }
|
package/src/version.js
ADDED
package/src/web.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
const https = require("https")
|
|
2
|
+
const { Mutex } = require("async-mutex")
|
|
3
|
+
const VERSION = require("../src/version")
|
|
4
|
+
|
|
5
|
+
class DispatchError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = "DispatchError"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class Web {
|
|
13
|
+
constructor(configuration) {
|
|
14
|
+
this._buffer = {}
|
|
15
|
+
this._mutex = new Mutex()
|
|
16
|
+
this._dispatcherRunning = false
|
|
17
|
+
this._configuration = configuration
|
|
18
|
+
this._dispatchInterval = 1
|
|
19
|
+
this._dispatchTimeout = 5
|
|
20
|
+
this._bufferTTL = 60
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async startDispatcher() {
|
|
24
|
+
const release = await this._mutex.acquire()
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
if (this._dispatcherRunning) return false
|
|
28
|
+
this._dispatcherRunning = true
|
|
29
|
+
} finally {
|
|
30
|
+
release()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this._logger.info("[HireFire] Starting web metrics dispatcher.")
|
|
34
|
+
|
|
35
|
+
this.dispatcher = setInterval(
|
|
36
|
+
this._dispatchBuffer.bind(this),
|
|
37
|
+
this._dispatchInterval * 1000,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async stopDispatcher() {
|
|
44
|
+
const release = await this._mutex.acquire()
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (!this._dispatcherRunning) return false
|
|
48
|
+
this._dispatcherRunning = false
|
|
49
|
+
clearInterval(this.dispatcher)
|
|
50
|
+
} finally {
|
|
51
|
+
release()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await this._flushBuffer()
|
|
55
|
+
|
|
56
|
+
this._logger.info("[HireFire] Web metrics dispatcher stopped.")
|
|
57
|
+
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
dispatcherRunning() {
|
|
62
|
+
return this._dispatcherRunning
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async addToBuffer(requestQueueTime) {
|
|
66
|
+
const release = await this._mutex.acquire()
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
70
|
+
this._buffer[timestamp] = this._buffer[timestamp] || []
|
|
71
|
+
this._buffer[timestamp].push(requestQueueTime)
|
|
72
|
+
} finally {
|
|
73
|
+
release()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async _flushBuffer() {
|
|
78
|
+
const release = await this._mutex.acquire()
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const currentBuffer = this._buffer
|
|
82
|
+
this._buffer = {}
|
|
83
|
+
return currentBuffer
|
|
84
|
+
} finally {
|
|
85
|
+
release()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async _dispatchBuffer() {
|
|
90
|
+
let buffer
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
buffer = await this._flushBuffer()
|
|
94
|
+
|
|
95
|
+
if (Object.keys(buffer).length === 0) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (process.env.HIREFIRE_VERBOSE) {
|
|
100
|
+
this._logger.info(
|
|
101
|
+
`[HireFire] Dispatching web metrics: ${JSON.stringify(buffer)}`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await this._submitBuffer(buffer)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
await this._repopulateBuffer(buffer)
|
|
108
|
+
this._logger.error(
|
|
109
|
+
`[HireFire] Error while dispatching web metrics: ${error.message}`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async _repopulateBuffer(buffer) {
|
|
115
|
+
const release = await this._mutex.acquire()
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const now = Math.floor(Date.now() / 1000)
|
|
119
|
+
Object.entries(buffer).forEach(([timestamp, requestQueueTimes]) => {
|
|
120
|
+
if (parseInt(timestamp) >= now - this._bufferTTL) {
|
|
121
|
+
this._buffer[timestamp] = this._buffer[timestamp] || []
|
|
122
|
+
this._buffer[timestamp].push(...requestQueueTimes)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
} finally {
|
|
126
|
+
release()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async _submitBuffer(buffer) {
|
|
131
|
+
const hirefireToken = process.env.HIREFIRE_TOKEN
|
|
132
|
+
|
|
133
|
+
if (!hirefireToken) {
|
|
134
|
+
throw new DispatchError(
|
|
135
|
+
"The HIREFIRE_TOKEN environment variable is not set. Unable to submit " +
|
|
136
|
+
"Request Queue Time metric data. The HIREFIRE_TOKEN can be found in " +
|
|
137
|
+
"the HireFire Web UI in the web dyno manager settings.",
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const data = JSON.stringify(buffer)
|
|
142
|
+
const dispatchUrl = (
|
|
143
|
+
process.env.HIREFIRE_DISPATCH_URL || "logdrain.hirefire.io"
|
|
144
|
+
).replace(/^(https?:\/\/)/, "")
|
|
145
|
+
const options = {
|
|
146
|
+
hostname: dispatchUrl,
|
|
147
|
+
port: 443,
|
|
148
|
+
path: "/",
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: {
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
"HireFire-Token": hirefireToken,
|
|
153
|
+
"HireFire-Resource": `Node-${VERSION}`,
|
|
154
|
+
"Content-Length": data.length,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const req = https.request(options, (res) => {
|
|
160
|
+
if (res.statusCode === 200) {
|
|
161
|
+
this._adjustParameters(res)
|
|
162
|
+
resolve()
|
|
163
|
+
} else if (res.statusCode >= 500) {
|
|
164
|
+
reject(
|
|
165
|
+
new DispatchError(
|
|
166
|
+
`Server responded with ${res.statusCode} status.`,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
} else {
|
|
170
|
+
reject(
|
|
171
|
+
new DispatchError(`Unexpected response code ${res.statusCode}.`),
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
req.on("error", (e) => {
|
|
177
|
+
if (e.code === "ETIMEDOUT" || e.code === "ESOCKETTIMEDOUT") {
|
|
178
|
+
reject(new DispatchError("Request timed out."))
|
|
179
|
+
} else {
|
|
180
|
+
reject(new DispatchError(`Network error occurred (${e.message}).`))
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
req.on("timeout", () => {
|
|
185
|
+
req.destroy()
|
|
186
|
+
reject(new DispatchError("Request timed out."))
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
req.setTimeout(this._dispatchTimeout * 1000)
|
|
190
|
+
req.write(data)
|
|
191
|
+
req.end()
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_adjustParameters(res) {
|
|
196
|
+
if (res.headers["hirefire-resource-dispatcher-interval"]) {
|
|
197
|
+
this._dispatchInterval = parseInt(
|
|
198
|
+
res.headers["hirefire-resource-dispatcher-interval"],
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
if (res.headers["hirefire-resource-dispatcher-timeout"]) {
|
|
202
|
+
this._dispatchTimeout = parseInt(
|
|
203
|
+
res.headers["hirefire-resource-dispatcher-timeout"],
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
if (res.headers["hirefire-resource-buffer-ttl"]) {
|
|
207
|
+
this._bufferTTL = parseInt(res.headers["hirefire-resource-buffer-ttl"])
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
get _logger() {
|
|
212
|
+
return this._configuration.logger
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { Web, DispatchError }
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
class InvalidDynoNameError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = "InvalidDynoNameError"
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class MissingDynoFnError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = "MissingDynoFnError"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class Worker {
|
|
16
|
+
static PROCESS_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,29}$/
|
|
17
|
+
|
|
18
|
+
constructor(name, fn) {
|
|
19
|
+
this._validate(name, fn)
|
|
20
|
+
this.name = name
|
|
21
|
+
this._fn = fn
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async value() {
|
|
25
|
+
return this._fn()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_validate(name, fn) {
|
|
29
|
+
if (!Worker.PROCESS_NAME_PATTERN.test(name || "")) {
|
|
30
|
+
throw new InvalidDynoNameError(
|
|
31
|
+
`Invalid name for new Worker(${name}, fn). ` +
|
|
32
|
+
"Ensure it matches the Procfile process name (i.e. web, worker).",
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!fn || typeof fn !== "function") {
|
|
37
|
+
throw new MissingDynoFnError(
|
|
38
|
+
`Missing function for new Worker(${name}, fn). ` +
|
|
39
|
+
"Ensure that you provide a function that returns the job queue metric.",
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { Worker, InvalidDynoNameError, MissingDynoFnError }
|