impress-2020/pages/api/outfitImage.js
Matchu 19f1ec092e Turn on Honeycomb instrumentation again
Well, instrumentation seems to be working fine again! The bug we ran into during commit e5081dab7e is gone. Cool!

I want to be able to see what's making the new box slow. My hypothesis was (and it seems to be right) that communication with the database on the Classic DTI server is slow.

But now that they're on the same Linode account and region, I think I can set up a private VLAN to make them muuuch faster. We'll try it out!
2021-11-26 23:41:22 -08:00

224 lines
8.1 KiB
JavaScript

/**
* /api/outfitImage returns an image of an outfit!
*
* Parameters:
* - size: Must be "150", "300", or "600", to indicate the image size you'd
* like back. (For example, "150" will return a 150x150 image.)
* - layerUrls: A comma-separated list of URLs to render, in order from
* bottom to top. This is a sorta "independent" render mode,
* not bound to any saved outfit. The URLs must match a known
* layer URL format. This mode will return a long-term cache
* header, so the client and our CDN cache can cache the
* requested URL forever. (NOTE: The Vercel cache seems pretty
* quick to eject them, though...)
* - id: Instead of `layerUrls`, you can instead provide an outfit ID, which
* will load the outfit data and render it directly. By default, this
* will return a 10-minute cache header, to keep individual users from
* re-loading the image from scratch too often, while still keeping it
* relatively fresh. (If you provide `updatedAt` too, we cache it for
* longer!)
* - updatedAt: If you provide an `id`, you may also provide `updatedAt`:
* the UNIX timestamp for when the outfit was last updated. This
* has no effect on image output, but it enables us to return a
* long-term cache header, so the client and our CDN cache can
* cache the requested URL forever. (NOTE: The Vercel cache
* seems pretty quick to eject them, though...)
*/
const beeline = require("honeycomb-beeline")({
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
dataset:
process.env["NODE_ENV"] === "production"
? "Dress to Impress (2020)"
: "Dress to Impress (2020, dev)",
serviceName: "impress-2020-gql-server",
sampleRate: 10,
});
import gql from "graphql-tag";
import { ApolloServer } from "apollo-server";
import { createTestClient } from "apollo-server-testing";
import connectToDb from "../../src/server/db";
import { config as graphqlConfig } from "../../src/server";
import { renderOutfitImage } from "../../src/server/outfit-images";
import getVisibleLayers, {
petAppearanceFragmentForGetVisibleLayers,
itemAppearanceFragmentForGetVisibleLayers,
} from "../../src/shared/getVisibleLayers";
const VALID_LAYER_URLS = [
/^https:\/\/(impress-asset-images\.openneo\.net|impress-asset-images\.s3\.amazonaws\.com)\/(biology|object)\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[0-9]+\/(150|300|600)x(150|300|600)\.png(\?[a-zA-Z0-9_-]+)?$/,
/^http:\/\/images\.neopets\.com\/cp\/(bio|object|items)\/data\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\.(svg|png)(\?.*)?$/,
// NOTE: We don't validate the layer's libraryUrl, because assetImage performs validation better than we could!
/^https:\/\/impress-2020\.openneo\.net\/api\/assetImage\?libraryUrl=[^&]+(&size=(150|300|600))?$/,
];
async function handle(req, res) {
const size = parseInt(req.query.size);
if (size !== 150 && size !== 300 && size !== 600) {
return reject(res, `Size must be 150, 300, or 600`);
}
let layerUrls;
if (req.query.layerUrls) {
layerUrls = req.query.layerUrls.split(",");
} else if (req.query.id && req.query.updatedAt) {
const outfitId = req.query.id;
try {
layerUrls = await loadLayerUrlsForSavedOutfit(outfitId, size);
} catch (e) {
console.error(e);
return reject(
res,
`Error loading data for outfit ${outfitId}: ${e.message}`,
500
);
}
} else if (req.query.id) {
// If there's an outfit ID, but no `updatedAt`, redirect to the URL with
// `updatedAt` added. (NOTE: Our Fastly config will try to handle this
// redirect internally, instead of making the user do a round-trip! That
// way, we load the version cached at the CDN instead of regenerating it,
// if possible.)
const outfitId = req.query.id;
let updatedAt;
try {
updatedAt = await loadUpdatedAtForSavedOutfit(outfitId);
} catch (e) {
return reject(
res,
`Error loading data for outfit ${outfitId}: ${e.message}`,
500
);
}
const updatedAtTimestamp = Math.floor(updatedAt.getTime() / 1000);
const urlWithUpdatedAt =
`/outfits` +
`/${encodeURIComponent(outfitId)}` +
`/v/${encodeURIComponent(updatedAtTimestamp)}` +
`/${encodeURIComponent(req.query.size)}.png`;
// Cache this result for 10 minutes, so individual users don't wait on
// image reloads too much, but it's still always relatively fresh!
res.setHeader("Cache-Control", "public, max-age=600");
return res.redirect(urlWithUpdatedAt);
} else {
return reject(res, `Missing required parameter: layerUrls`);
}
for (const layerUrl of layerUrls) {
if (!VALID_LAYER_URLS.some((pattern) => layerUrl.match(pattern))) {
return reject(res, `Unexpected layer URL format: ${layerUrl}`);
}
}
let imageResult;
try {
imageResult = await renderOutfitImage(layerUrls, size);
} catch (e) {
console.error(e);
return reject(res, `Error rendering image: ${e.message}`);
}
const { image, status } = imageResult;
if (status === "success") {
// This image is ready, and it either used `layerUrls` or `updatedAt`, so
// it shouldn't change much, if ever. Send a long-term cache header!
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.status(200);
} else {
// On partial failure, we still send the image, but with a 500 status. We
// send a one-week cache header, but in such a way that the user can
// refresh the page to try again. (`private` means the CDN won't cache it,
// and we don't send `immutable`, which would save it even across reloads.)
// The 500 won't really affect the client, which will still show the image
// without feedback to the user - but it's a helpful debugging hint.
res.setHeader("Cache-Control", "private, max-age=604800");
res.status(500);
}
res.setHeader("Content-Type", "image/png");
return res.send(image);
}
// Check out this scrappy way of making a query against server code ^_^`
const graphqlClient = createTestClient(new ApolloServer(graphqlConfig));
async function loadLayerUrlsForSavedOutfit(outfitId, size) {
const { errors, data } = await graphqlClient.query({
query: gql`
query ApiOutfitImage($outfitId: ID!, $size: LayerImageSize) {
outfit(id: $outfitId) {
petAppearance {
layers {
id
imageUrl(size: $size)
}
...PetAppearanceForGetVisibleLayers
}
itemAppearances {
layers {
id
imageUrl(size: $size)
}
...ItemAppearanceForGetVisibleLayers
}
}
}
${petAppearanceFragmentForGetVisibleLayers}
${itemAppearanceFragmentForGetVisibleLayers}
`,
variables: { outfitId, size: `SIZE_${size}` },
});
if (errors && errors.length > 0) {
throw new Error(
`GraphQL Error: ${errors.map((e) => e.message).join(", ")}`
);
}
if (!data.outfit) {
throw new Error(`outfit ${outfitId} not found`);
}
const { petAppearance, itemAppearances } = data.outfit;
const visibleLayers = getVisibleLayers(petAppearance, itemAppearances);
for (const layer of visibleLayers) {
if (!layer.imageUrl) {
throw new Error(`layer ${layer.id} has no imageUrl for size ${size}`);
}
}
return visibleLayers
.sort((a, b) => a.depth - b.depth)
.map((layer) => layer.imageUrl);
}
async function loadUpdatedAtForSavedOutfit(outfitId) {
const db = await connectToDb();
const [rows] = await db.query(`SELECT updated_at FROM outfits WHERE id = ?`, [
outfitId,
]);
const row = rows[0];
if (!row) {
throw new Error(`outfit ${outfitId} not found`);
}
return row.updated_at;
}
function reject(res, message, status = 400) {
res.setHeader("Content-Type", "text/plain; charset=utf8");
return res.status(status).send(message);
}
async function handleWithBeeline(req, res) {
beeline.withTrace(
{ name: "api/outfitImage", operation_name: "api/outfitImage" },
() => handle(req, res)
);
}
export default handleWithBeeline;