Skip to content

Commit

Permalink
feat: add request ID threading (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
chadian authored Sep 9, 2020
1 parent dd1a404 commit 9c9d66e
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 99 deletions.
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"heroku-client": "^3.1.0",
"http-call": "^5.2.4",
"netrc-parser": "^3.1.6",
"open": "^6.2.0"
"open": "^6.2.0",
"uuid": "^8.3.0"
},
"devDependencies": {
"@heroku-cli/schema": "^1.0.25",
Expand All @@ -28,15 +29,15 @@
"@types/nock": "^9.3.1",
"@types/node": "^11.13.7",
"@types/proxyquire": "^1.3.28",
"@types/sinon": "^7.0.11",
"@types/sinon": "^9.0.5",
"@types/supports-color": "^5.3.0",
"@types/uuid": "^8.3.0",
"chai": "^4.2.0",
"fancy-test": "^1.4.3",
"mocha": "^6.1.4",
"nock": "^10.0.6",
"proxyquire": "^2.1.0",
"sinon": "^7.3.2",
"testdouble": "^3.11.0",
"sinon": "^9.0.3",
"ts-node": "^8.1.0",
"tslint": "^5.16.0",
"typescript": "^3.4.5"
Expand Down
15 changes: 14 additions & 1 deletion src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as url from 'url'
import deps from './deps'
import {Login} from './login'
import {Mutex} from './mutex'
import {RequestId, requestIdHeader} from './request-id'
import {vars} from './vars'

export namespace APIClient {
Expand Down Expand Up @@ -98,14 +99,26 @@ export class APIClient {
}
}

static trackRequestIds<T>(response: HTTP<T>) {
const responseRequestIdHeader = response.headers[requestIdHeader]
if (responseRequestIdHeader) {
const requestIds = Array.isArray(responseRequestIdHeader) ? responseRequestIdHeader : responseRequestIdHeader.split(',')
RequestId.track(...requestIds)
}
}

static async request<T>(url: string, opts: APIClient.Options = {}, retries = 3): Promise<APIHTTPClient<T>> {
opts.headers = opts.headers || {}
opts.headers[requestIdHeader] = RequestId.create() && RequestId.headerValue

if (!Object.keys(opts.headers).find(h => h.toLowerCase() === 'authorization')) {
opts.headers.authorization = `Bearer ${self.auth}`
}
retries--
try {
return await super.request<T>(url, opts)
const response = await super.request<T>(url, opts)
this.trackRequestIds<T>(response)
return response
} catch (err) {
if (!(err instanceof deps.HTTP.HTTPError)) throw err
if (retries > 0) {
Expand Down
34 changes: 34 additions & 0 deletions src/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as uuid from 'uuid'

export const requestIdHeader = 'Request-Id'

// tslint:disable-next-line: no-unnecessary-class
export class RequestId {
static ids: string[] = []

static track(...ids: string[]) {
const tracked = RequestId.ids
ids = ids.filter(id => !(tracked.includes(id)))
RequestId.ids = [...ids, ...tracked]
return RequestId.ids
}

static create(): string[] {
const tracked = RequestId.ids
const generatedId = RequestId._generate()
RequestId.ids = [generatedId, ...tracked]
return RequestId.ids
}

static empty(): void {
RequestId.ids = []
}

static get headerValue() {
return RequestId.ids.join(',')
}

static _generate() {
return uuid.v4()
}
}
55 changes: 55 additions & 0 deletions test/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import * as Config from '@oclif/config'
import cli from 'cli-ux'
import base, {expect} from 'fancy-test'
import nock from 'nock'
import * as sinon from 'sinon'

import {Command as CommandBase} from '../src/command'
import {RequestId, requestIdHeader} from '../src/request-id'

// tslint:disable no-http-string

Expand Down Expand Up @@ -125,4 +127,57 @@ describe('api_client', () => {
expect((await _config).body).to.deep.equal({foo: 'bar'})
expect((await dynos).body).to.deep.equal({web: 1})
})

context('request ids', function () {
let generateStub: any

beforeEach(function () {
RequestId.empty()
generateStub = sinon.stub(RequestId, '_generate')
})

afterEach(function () {
generateStub.restore()
})

test
.it('makes requests with a generated request id', async ctx => {
const cmd = new Command([], ctx.config)

generateStub.returns('random-uuid')
api = nock('https://api.heroku.com').get('/apps').reply(200, [{name: 'myapp'}])

const {request} = await cmd.heroku.get('/apps')
expect(request.getHeader(requestIdHeader)).to.deep.equal('random-uuid')
})

test
.it('makes requests including previous request ids', async ctx => {
const cmd = new Command([], ctx.config)
api = nock('https://api.heroku.com').get('/apps').twice().reply(200, [{name: 'myapp'}])

generateStub.returns('random-uuid')
await cmd.heroku.get('/apps')

generateStub.returns('second-random-uuid')
const {request: secondRequest} = await cmd.heroku.get('/apps')

expect(secondRequest.getHeader(requestIdHeader)).to.deep.equal('second-random-uuid,random-uuid')
})

test
.it('tracks response request ids for subsequent request ids', async ctx => {
const cmd = new Command([], ctx.config)
const existingRequestIds = ['first-existing-request-id', 'second-existing-request-id'].join(',')
api = nock('https://api.heroku.com').get('/apps').twice().reply(() => [200, JSON.stringify({name: 'myapp'}), {[requestIdHeader]: existingRequestIds}])

generateStub.returns('random-uuid')
await cmd.heroku.get('/apps')

generateStub.returns('second-random-uuid')
const {request: secondRequest} = await cmd.heroku.get('/apps')

expect(secondRequest.getHeader(requestIdHeader)).to.deep.equal('second-random-uuid,first-existing-request-id,second-existing-request-id,random-uuid')
})
})
})
76 changes: 76 additions & 0 deletions test/request-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {expect} from 'chai'
import * as sinon from 'sinon'

import {RequestId} from '../src/request-id'

describe('getRequestId', () => {
let generateStub: any

beforeEach(function () {
RequestId.empty()
generateStub = sinon.stub(RequestId, '_generate').returns('randomly-generated-uuid')
})

afterEach(function () {
generateStub.restore()
})

it('can create random uuids', () => {
expect(RequestId.ids.length).to.equal(0)
expect(generateStub.called).to.be.false
const ids = RequestId.create()
expect(ids).to.deep.equal(RequestId.ids)
expect(ids).to.deep.equal(['randomly-generated-uuid'])
expect(RequestId.ids.length).to.equal(1)
expect(generateStub.called).to.be.true
})

it('can can track ids', () => {
expect(RequestId.ids.length).to.equal(0)
RequestId.track('tracked-id', 'another-tracked-id')
expect(RequestId.ids).to.deep.equal(['tracked-id', 'another-tracked-id'])
})

it('can empty the tracked ids', () => {
expect(RequestId.ids.length).to.equal(0)
RequestId.create()
expect(RequestId.ids.length).to.equal(1)
RequestId.empty()
expect(RequestId.ids.length).to.equal(0)
})

it('can generate a header value', () => {
RequestId.create()
RequestId.track('incoming-header-id')
expect(RequestId.headerValue).to.equal('incoming-header-id,randomly-generated-uuid')
})

it('create and track uuids together putting latest in front', () => {
expect(RequestId.ids.length).to.equal(0)

generateStub.returns('random')
let ids = RequestId.create()
expect(ids).to.deep.equal(['random'])

ids = RequestId.track('tracked')
expect(ids).to.deep.equal(['tracked', 'random'])

generateStub.returns('another-random')
ids = RequestId.create()
expect(RequestId.ids).to.deep.equal(['another-random', 'tracked', 'random'])
expect(RequestId.headerValue).to.deep.equal('another-random,tracked,random')
})

it('tracked ids are not added if they are already included', function () {
expect(RequestId.ids).to.deep.equal([])

RequestId.track('tracked')
expect(RequestId.ids).to.deep.equal(['tracked'])

RequestId.track('tracked')
expect(RequestId.ids).to.deep.equal(['tracked'])

RequestId.track('other-tracked', 'tracked', 'another-tracked')
expect(RequestId.ids).to.deep.equal(['other-tracked', 'another-tracked', 'tracked'])
})
})
Loading

0 comments on commit 9c9d66e

Please sign in to comment.