Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add metrics exporting for: #36

Merged
merged 1 commit into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,22 @@
"profile": "tsc && node --prof ./dist/index.js"
},
"dependencies": {
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.50.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.50.0",
"@opentelemetry/instrumentation": "^0.50.0",
"@opentelemetry/instrumentation-http": "^0.50.0",
"@opentelemetry/resources": "^1.23.0",
"@opentelemetry/sdk-trace-base": "^1.23.0",
"@opentelemetry/sdk-trace-node": "^1.23.0",
"@opentelemetry/semantic-conventions": "^1.23.0",
"@react-pdf/renderer": "^3.0.0",
"ajv": "^8.11.0",
"axios": "^0.28.0",
"cors": "^2.8.5",
"fontkit": "2",
"fonts": "^0.0.2",
"prometheus-middleware": "^1.3.5",
"react": "^16.12.0",
"uuid": "^3.3.3",
"vm2": "^3.9.18",
Expand Down
314 changes: 177 additions & 137 deletions src/PdfController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,57 @@ import { IncomingMessage, ServerResponse } from 'http';
import ReactPDF from '@react-pdf/renderer';
import { PdfRequest } from './wire/PdfRequest';
import { ElementFactory } from './factory/ElementFactory';
import { v4 } from 'uuid';
import uuid, { v4 } from 'uuid';
import fs from 'fs';
import path from 'path';
import {tmpdir} from 'os';
import {promisify} from 'util';
import { tmpdir } from 'os';
import { promisify } from 'util';
import { ILogger } from './ILogger';
import { validatePdfRequest } from './validatePdfRequest';
import { apm } from './metrics';
import { tracer } from './trace';
import { SpanStatusCode } from '@opentelemetry/api';

// We prefer async-await where we can
const mkdir = promisify(fs.mkdir);

/// This represents the handling of incoming PDF creation requests
export class PdfController
{
public constructor(private request: IncomingMessage, private res: ServerResponse, private logger:ILogger, private config: {
ValidateApiPayloads: boolean;
GoogleApiKey: string
}) { }
export class PdfController {
private static readonly renderCount = new apm.client.Counter({ name: 'pdf_render_count', help: 'How many PDFS have been attempted.', labelNames: ["RequestId"] });
private static readonly successCount = new apm.client.Counter({ name: 'pdf_render_success_count', help: 'How many PDFS have been successfully returned.', labelNames: ["RequestId"] });
private static readonly failCount = new apm.client.Counter({ name: 'pdf_render_fail_count', help: 'How many PDFS have failed to return.', labelNames: ["RequestId"] });
private static readonly duration = new apm.client.Histogram({ name: 'pdf_render_duration', help: 'The duration of specific generation stages.', labelNames: ["Stage"] });

private readonly endReadIncomingTimer: (labels?: Partial<Record<string, string | number>> | undefined) => number;
private readonly requestId: string = uuid();

private async trackSpan<T>(stage: string, func: () => T) {
const timer = PdfController.duration.startTimer({ Stage: stage });
const span = tracer.startSpan(stage, { kind: 1 });
try {
const r = func();
span.setStatus({ code: SpanStatusCode.OK });
return r;
}
catch(err)
{
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
throw err;
}
finally
{
span.end();
timer();
}
}

public constructor(private request: IncomingMessage, private res: ServerResponse, private logger: ILogger, private config: {
ValidateApiPayloads: boolean;
GoogleApiKey: string
}) {
PdfController.renderCount.inc({ RequestId: this.requestId });
this.endReadIncomingTimer = PdfController.duration.startTimer({ Stage: "ingest" });
}

private body = '';

Expand All @@ -39,167 +72,174 @@ export class PdfController

// What to do once the data has completed being read
public readonly onEnd = async () => {
try{
this.logger.info(`${Date.now().toString()} Completed receiving request POST ${this.request.url}` );
try {
this.logger.info(`${Date.now().toString()} Completed receiving request POST ${this.request.url}`);
// The incoming data is expected to be a standard json request
let start = Date.now();
const postBody = JSON.parse(this.body) as PdfRequest;
let end = Date.now();
this.logger.info(`${(end-start)/1000} Parsed request POST ${this.request.url}` );

start = Date.now();
if((this.config.ValidateApiPayloads || postBody.strict) && !validatePdfRequest(postBody))
{
this.logger.info("Validating the payload");
// Capture the validation errors and throw the exception.
const errors = validatePdfRequest.errors;
this.logger.error(`Errors validating an uploaded pdf request`, {
errors
});
this.res.statusCode = 400;
this.res.end(`The request was not valid: ${JSON.stringify(errors, null, 2)}.`);
return;
}
end = Date.now();
this.logger.info(`${(end-start)/1000} Validated request POST ${this.request.url}`);

// Create a temporary folder for our output, if needed
const tmpPath = path.normalize(`${tmpdir()}/pdf-renderer`)
try{
await mkdir(tmpPath, { recursive: true });
}
catch(err)
{
// We get an exception if the directory already exists, but this
// is the only safe way to handle it. If we check if it exists before, there's
// no guarantee that won't change anyway, so we'd still have to handle this
// error.
const errAny = err as any;
if(errAny?.code !== 'EEXIST')
throw err;
}
this.logger.info(`${(end - start) / 1000} Parsed request POST ${this.request.url}`);
this.endReadIncomingTimer();

// Generate a random name in our tmp directory for this PDF
const pathname = path.normalize(`${tmpPath}/${v4()}.pdf`);
this.logger.info(`Writing to ${pathname}`);
await this.trackSpan('validate', async () => {
start = Date.now();
if ((this.config.ValidateApiPayloads || postBody.strict) && !validatePdfRequest(postBody)) {
this.logger.info("Validating the payload");
// Capture the validation errors and throw the exception.
const errors = validatePdfRequest.errors;
this.logger.error(`Errors validating an uploaded pdf request`, {
errors
});
this.res.statusCode = 400;
this.res.end(`The request was not valid: ${JSON.stringify(errors, null, 2)}.`);
PdfController.failCount.inc();
return;
}
end = Date.now();
this.logger.info(`${(end - start) / 1000} Validated request POST ${this.request.url}`);
});

let rootNode:React.ReactElement|null = null;
start = Date.now();
// server code
let rootNode: React.ReactElement | null = null;
let pathname: string;
// Create the factory that will generate nodes based on incoming json
const factory = new ElementFactory(postBody, this.logger, this.config.GoogleApiKey);

try{
// Transform the incoming json into a react PDF element tree
rootNode = await factory.generate();
end = Date.now();
this.logger.info(`${(end-start)/1000} Generated request POST ${this.request.url}` );
}
catch (err) {
this.logger.error(`There was an error thrown from the generation process for POST ${this.request.url}`);
if(err instanceof Error)
{
this.logger.error(err);
this.res.statusCode = 400;
let errorMessage = `Error (${err.name}) rendering the file: ${err.message}`;
if((err as any).renderStack)
{
errorMessage += '\nRender Stack: ' + ((err as any).renderStack as string[]).join(' > ');
}
this.res.end(errorMessage);
await this.trackSpan('build', async () => {
// Create a temporary folder for our output, if needed
const tmpPath = path.normalize(`${tmpdir()}/pdf-renderer`)
try {
await mkdir(tmpPath, { recursive: true });
}
else
{
this.res.statusCode = 500;
this.res.end(`Error rendering the file: ${err}.`);
catch (err) {
// We get an exception if the directory already exists, but this
// is the only safe way to handle it. If we check if it exists before, there's
// no guarantee that won't change anyway, so we'd still have to handle this
// error.
const errAny = err as any;
if (errAny?.code !== 'EEXIST')
throw err;
}
return;
}

try {
// Generate a random name in our tmp directory for this PDF
pathname = path.normalize(`${tmpPath}/${v4()}.pdf`);
this.logger.info(`Writing to ${pathname}`);

start = Date.now();
// Turn this react element tree into an actual PDF file
// There doesn't seem to be an in-memory version of this, so
// write it to the disk and then return the file on-disk to the client
await ReactPDF.render(rootNode, pathname);
end = Date.now();
this.logger.info(`${(end-start)/1000} Rendered request POST ${this.request.url}` );
}
catch (err) {
this.logger.error(`There was an error thrown from the rendering process for POST ${this.request.url}`);
// server code

const errAny = err as any;
if(errAny.message)
{
this.logger.error(errAny.message);
try {
// Transform the incoming json into a react PDF element tree
rootNode = await factory.generate();
end = Date.now();
this.logger.info(`${(end - start) / 1000} Generated request POST ${this.request.url}`);
}
this.res.statusCode = 500;
this.res.end(`Error rendering the file: ${err}.`);
return;
}

start = Date.now();
// Now that it's written to disk, we have to read it back
fs.readFile(pathname, (err, data) => {
try{
// If there was an error reading, pass it back. We do a 500 error here
// rather than Skyward's standard API response because the normal path
// returns file bytes, not an API response, so the client would have a
// hard time distinguishing.
if (err) {
catch (err) {
this.logger.error(`There was an error thrown from the generation process for POST ${this.request.url}`);
if (err instanceof Error) {
this.logger.error(err);
this.res.statusCode = 500;
this.res.end(`Error getting the file: ${err}.`);
return;
this.res.statusCode = 400;
let errorMessage = `Error (${err.name}) rendering the file: ${err.message}`;
if ((err as any).renderStack) {
errorMessage += '\nRender Stack: ' + ((err as any).renderStack as string[]).join(' > ');
}
this.res.end(errorMessage);
}
else {
// Set the filename to be the title, getting rid of invalid characters
const safeFileName = factory.finalizeString(postBody.title ?? 'document').replace(/[^A-Za-z0-9_.-]/g, '_');
// set Content-type and send data
this.res.setHeader('Content-type', 'application/pdf');
this.res.setHeader('Content-disposition', `attachment; filename="${safeFileName}.pdf"`);
this.logger.info(`Setting filename to ${safeFileName}`)
this.res.end(data);
end = Date.now();
this.logger.info(`${(end-start)/1000} Transmitted request POST ${this.request.url}` );
return;
this.res.statusCode = 500;
this.res.end(`Error rendering the file: ${err}.`);
}
PdfController.failCount.inc();
return;
}
catch(e)
{
// If there was an error in the responding code, instead send an error.
// There's a possibility this will be invalid if for some reason we get part way
// through responding above.
const errAny = e as any;
this.logger.error(errAny);
this.res.statusCode = 500;
if(errAny.toString)
{
this.res.end(`Error getting the file: ${errAny.toString()}.`);
}
else
{
this.res.end(`Error getting the file: ${errAny}.`);
});

await this.trackSpan('render', async () => {
try {
start = Date.now();
// Turn this react element tree into an actual PDF file
// There doesn't seem to be an in-memory version of this, so
// write it to the disk and then return the file on-disk to the client
await ReactPDF.render(rootNode!, pathname);
end = Date.now();
this.logger.info(`${(end - start) / 1000} Rendered request POST ${this.request.url}`);
}
catch (err) {
this.logger.error(`There was an error thrown from the rendering process for POST ${this.request.url}`);

const errAny = err as any;
if (errAny.message) {
this.logger.error(errAny.message);
}
this.res.statusCode = 500;
this.res.end(`Error rendering the file: ${err}.`);
PdfController.failCount.inc();
return;
}
});


await this.trackSpan('respond', async () => {
start = Date.now();
// Now that it's written to disk, we have to read it back
fs.readFile(pathname, (err, data) => {
try {
// If there was an error reading, pass it back. We do a 500 error here
// rather than Skyward's standard API response because the normal path
// returns file bytes, not an API response, so the client would have a
// hard time distinguishing.
if (err) {
this.logger.error(err);
this.res.statusCode = 500;
this.res.end(`Error getting the file: ${err}.`);
PdfController.failCount.inc();
return;
}
else {
// Set the filename to be the title, getting rid of invalid characters
const safeFileName = factory.finalizeString(postBody.title ?? 'document').replace(/[^A-Za-z0-9_.-]/g, '_');
// set Content-type and send data
this.res.setHeader('Content-type', 'application/pdf');
this.res.setHeader('Content-disposition', `attachment; filename="${safeFileName}.pdf"`);
this.logger.info(`Setting filename to ${safeFileName}`)
this.res.end(data);
end = Date.now();
this.logger.info(`${(end - start) / 1000} Transmitted request POST ${this.request.url}`);
PdfController.successCount.inc();
return;
}
}
catch (e) {
// If there was an error in the responding code, instead send an error.
// There's a possibility this will be invalid if for some reason we get part way
// through responding above.
const errAny = e as any;
this.logger.error(errAny);
this.res.statusCode = 500;
if (errAny.toString) {
this.res.end(`Error getting the file: ${errAny.toString()}.`);
}
else {
this.res.end(`Error getting the file: ${errAny}.`);
}
PdfController.failCount.inc();
return;
}
});
});
}
catch(e)
{
catch (e) {
// If there was an error in the generating code, send an error.
const errAny = e as any;
this.logger.error(errAny);
this.res.statusCode = 500;
if(errAny.toString)
{
if (errAny.toString) {
this.res.end(`Error generating the pdf: ${errAny.toString()}.`);
}
else
{
else {
this.res.end(`Error generating the pdf: ${errAny}.`);
}
PdfController.failCount.inc();
return;
}
};
}
}
Loading
Loading