Skip to content

Commit

Permalink
Enrich measurement event with error details (#23)
Browse files Browse the repository at this point in the history
* Enrich unsuccessful measurement with error details
* Map error into athena table
  • Loading branch information
Rocco Zanni authored Oct 31, 2023
1 parent 90f9ff5 commit a729731
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 35 deletions.
2 changes: 2 additions & 0 deletions analytics/deployment/resources/athena.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ Resources:
Type: string
- Name: traversal
Type: string
- Name: error
Type: string
43 changes: 43 additions & 0 deletions monitor/common/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@

class GenericError extends Error {
constructor(message) {
super(message);
this.name = 'GenericError';
this.code = 'ERROR_GENERIC';
}
}

export class UnexpectedRedirectLocationError extends GenericError {
constructor(location) {
const message = `Unexpected Redirect Location: ${location}`;
super(message);
Error.captureStackTrace(this, UnexpectedRedirectLocationError);
this.name = 'UnexpectedRedirectLocationError';
this.message = message;
this.code = 'ERROR_UNEXPECTED_REDIRECT_LOCATION';
}
}

export class UnexpectedHttpStatusError extends GenericError {
constructor(status) {
const message = `Unexpected HTTP Status: ${status}`;
super(message);
Error.captureStackTrace(this, UnexpectedHttpStatusError);
this.name = 'UnexpectedHttpStatusError';
this.message = message;
this.code = 'ERROR_UNEXPECTED_HTTP_STATUS';
}
}

export const serializeError = function (error) {

if (!error) {
return null;
}

return {
name: error.name,
code: error.code,
message: error.message
}
}
85 changes: 53 additions & 32 deletions monitor/functions/check-endpoint/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PutEventsCommand, EventBridgeClient } from "@aws-sdk/client-eventbridge
import { formatISO } from 'date-fns';
import { request, Agent } from 'undici';
import { URL } from 'node:url';
import { UnexpectedRedirectLocationError, UnexpectedHttpStatusError, serializeError } from "../../common/errors.js";

const { randomUUID } = await import('node:crypto');

Expand All @@ -16,16 +17,7 @@ const createAgent = function (options) {
return new Agent(options);
}

const performCheck = async function (service, agentFactory) {

const result = {
id: randomUUID(),
timestamp: formatISO(new Date()),
region: process.env.AWS_REGION,
endpoint: service.endpoint,
url: service.url,
type: service.type
};
const performHTTPRequest = async function (id, service, agentFactory) {

/**
* HTTP Connection lifecycle
Expand All @@ -48,49 +40,78 @@ const performCheck = async function (service, agentFactory) {
connect: { timeout: 5000 },
});

let statusCode = 0, headers = null, context = null;
let statusCode = 0, headers = null, context = null, error = null;

const start = hrtime.bigint();

try {
({ statusCode, headers, context } = await request(result.url, {
({ statusCode, headers, context } = await request(service.url, {
dispatcher: agent,
method: 'HEAD',
headers: { "User-Agent": `Mozilla/5.0 (compatible; PodUptimeBot/1.0; +https://poduptime.com; rid:${result.id})` },
headers: { "User-Agent": `Mozilla/5.0 (compatible; PodUptimeBot/1.0; +https://poduptime.com; rid:${id})` },
}));
} catch (e) {
console.error("Check endpoint error", result, e);
error = e;
console.error("Check endpoint error", service, e);
}

const durationMs = getDurationMs(start);
const duration = getDurationMs(start);

try {
await agent.destroy();
} catch (e) { }

const traversal = (context?.history ?? [new URL(result.url)]).map((u) => {
const traversal = (context?.history ?? [new URL(service.url)]).map((u) => {
return u.href;
});

/**
* Availability is defined differently based on the endpoint type.
*
* - Prefix endpoints are considered available when they redirect to
* the expected url
* - Other endpoints are considered available when they respond with
* an HTTP 200 status code
*/
const available = "prefix" !== service.type ? statusCode === 200 :
[301, 302, 303, 307, 308].includes(statusCode) &&
service.expected_url === headers["location"];
return {
status: statusCode, headers, error, traversal, duration
};
}

const determineAvailability = function (service, response) {

// If HTTP request triggered an error, we let it bubble
if (response.error) {
return { available: 0, error: serializeError(response.error) };
}

// Prefix service must redirect
if ("prefix" === service.type && ![301, 302, 303, 307, 308].includes(response.status)) {
return { available: 0, error: serializeError(new UnexpectedHttpStatusError(response.status)) };
}

// Prefix service must redirect to the expected location
if ("prefix" === service.type && service.expected_url !== response.headers["location"]) {
return { available: 0, error: serializeError(new UnexpectedRedirectLocationError(response.headers["location"])) };
}

// Other services should return 200
if ("prefix" !== service.type && response.status !== 200) {
return { available: 0, error: serializeError(new UnexpectedHttpStatusError(response.status)) };
}

return { available: 1, error: null };
}

const performCheck = async function (service, agentFactory) {

const result = {
id: randomUUID(),
timestamp: formatISO(new Date()),
region: process.env.AWS_REGION,
endpoint: service.endpoint,
url: service.url,
type: service.type
};

const response = await performHTTPRequest(result.id, service, agentFactory);

return {
...result,
status: statusCode,
duration: durationMs,
headers: headers,
traversal: traversal,
available: available ? 1 : 0
...response,
...determineAvailability(service, response)
};
}

Expand Down
81 changes: 78 additions & 3 deletions monitor/functions/check-endpoint/handler_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PutEventsCommand } from "@aws-sdk/client-eventbridge";
import { checkEndpoint } from "./handler.js"
import { MockAgent } from 'undici';
import { use } from "../../common/fixtures.js";
import { UnexpectedRedirectLocationError, UnexpectedHttpStatusError, serializeError } from "../../common/errors.js";

const assertEventBridgePayload = function (events, expected) {

Expand All @@ -17,6 +18,7 @@ const assertEventBridgePayload = function (events, expected) {
timestamp: /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z)/, // is an ISO 8601 timestamp
duration: /\d+.\d+/, // is a floating point
endpoint: "test",
error: null,
...expected,
}

Expand All @@ -27,8 +29,9 @@ const assertEventBridgePayload = function (events, expected) {
assert.equal(actual.status, expected.status);
assert.deepStrictEqual(actual.headers, expected.headers);
assert.deepStrictEqual(actual.traversal, expected.traversal);
assert.equal(actual.available, expected.available);
assert.equal(actual.type, expected.type);
assert.deepStrictEqual(actual.error, expected.error);
assert.equal(actual.available, expected.available);
}

describe('monitor - checkEndpoint', () => {
Expand Down Expand Up @@ -72,6 +75,37 @@ describe('monitor - checkEndpoint', () => {
});
});

it('should mark unavailable an enclosure responding with wrong http status', async () => {

const service = {
type: "enclosure",
endpoint: "test",
url: "https://poduptime.com/test.mp3"
};

await checkEndpoint({ Records: [{ body: JSON.stringify(service) }] }, {
agentFactory: (options) => {

const mockAgent = new MockAgent(options);
mockAgent.disableNetConnect();

mockAgent.get("https://poduptime.com").intercept({ path: "/test.mp3", method: 'HEAD' }).reply(404);

return mockAgent;
}
});

assertEventBridgePayload(events, {
url: "https://poduptime.com/test.mp3",
status: 404,
headers: {},
traversal: ["https://poduptime.com/test.mp3"],
available: 0,
type: "enclosure",
error: serializeError(new UnexpectedHttpStatusError(404))
});
});

it('should check prefix endpoint, not following redirects, and send result to EventBridge', async () => {

const service = {
Expand Down Expand Up @@ -109,6 +143,38 @@ describe('monitor - checkEndpoint', () => {
});
});

it('should mark unavailable a prefix responding with wrong http status', async () => {

const service = {
type: "prefix",
endpoint: "test",
url: "https://prefix.com/poduptime.com/test.mp3",
expected_url: "https://poduptime.com/test.mp3"
};

await checkEndpoint({ Records: [{ body: JSON.stringify(service) }] }, {
agentFactory: (options) => {

const mockAgent = new MockAgent(options);
mockAgent.disableNetConnect();

mockAgent.get("https://prefix.com").intercept({ path: "/poduptime.com/test.mp3", method: 'HEAD' }).reply(200);

return mockAgent;
}
});

assertEventBridgePayload(events, {
url: "https://prefix.com/poduptime.com/test.mp3",
status: 200,
headers: {},
traversal: ["https://prefix.com/poduptime.com/test.mp3"],
available: 0,
type: "prefix",
error: serializeError(new UnexpectedHttpStatusError(200))
});
});

it('should mark unavailable a prefix redirecting to the wrong place', async () => {

const service = {
Expand Down Expand Up @@ -138,7 +204,8 @@ describe('monitor - checkEndpoint', () => {
headers: { location: "https://poduptime.com/wrong.mp3" },
traversal: ["https://prefix.com/poduptime.com/test.mp3"],
available: 0,
type: "prefix"
type: "prefix",
error: serializeError(new UnexpectedRedirectLocationError("https://poduptime.com/wrong.mp3"))
});
});

Expand All @@ -152,12 +219,19 @@ describe('monitor - checkEndpoint', () => {
url: "https://poduptime.com/test.mp3"
};

const error = new Error("Mock Error message");
error.code = "MOCK_ERROR";

await checkEndpoint({ Records: [{ body: JSON.stringify(service) }] }, {
agentFactory: (options) => {

const mockAgent = new MockAgent(options);
mockAgent.disableNetConnect();

mockAgent.get("https://poduptime.com")
.intercept({ path: "test.mp3", method: 'HEAD' })
.replyWithError(error);

return mockAgent;
}
});
Expand All @@ -170,7 +244,8 @@ describe('monitor - checkEndpoint', () => {
headers: null,
traversal: ["https://poduptime.com/test.mp3"],
available: 0,
type: "enclosure"
type: "enclosure",
error: serializeError(error)
});
});

Expand Down

0 comments on commit a729731

Please sign in to comment.