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

feat: worker log and revision #1

Closed
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
5 changes: 4 additions & 1 deletion src/sdk/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,16 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TSu
env = process.env as TEnv;
}


Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change


Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

const context: Context<TConfig, TEnv, TSupportedEvents> = {
eventName: inputs.eventName as TSupportedEvents,
payload: JSON.parse(inputs.eventPayload),
octokit: new customOctokit({ auth: inputs.authToken }),
config: config,
env: env,
logger: new Logs(pluginOptions.logLevel),
pluginDeploymentDetails: getGithubWorkflowRunUrl()
};

try {
Expand Down Expand Up @@ -120,7 +123,7 @@ async function postErrorComment(context: Context, error: LogReturn) {
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
issue_number: context.payload.issue.number,
body: `${error.logMessage.diff}\n<!--\n${getGithubWorkflowRunUrl()}\n${sanitizeMetadata(error.metadata)}\n-->`,
body: `${error.logMessage.diff}\n<!--\n${context.pluginDeploymentDetails}\n${sanitizeMetadata(error.metadata)}\n-->`,
});
} else {
context.logger.info("Cannot post error comment because issue is not found in the payload");
Expand Down
130 changes: 112 additions & 18 deletions src/sdk/comment.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,84 @@
import { Context } from "./context";
import { LogReturn } from "@ubiquity-os/ubiquity-os-logger";
import { LogReturn, Metadata } from "@ubiquity-os/ubiquity-os-logger";
import { sanitizeMetadata } from "./util";
import { CloudflareEnvBindings } from "./server";

const HEADER_NAME = "Ubiquity";

/**
* Posts a comment on a GitHub issue if the issue exists in the context payload, embedding structured metadata to it.
*/
export async function postComment(context: Context, message: LogReturn) {
if ("issue" in context.payload && context.payload.repository?.owner?.login) {
const metadata = createStructuredMetadata(message.metadata?.name, message);
await context.octokit.rest.issues.createComment({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
issue_number: context.payload.issue.number,
body: [message.logMessage.diff, metadata].join("\n"),
});
export async function postComment(context: Context, message: LogReturn | Error | null, honoEnv: CloudflareEnvBindings) {
if (!message) {
return;
}

let issueNumber

if ("issue" in context.payload) {
issueNumber = context.payload.issue.number;
} else if ("pull_request" in context.payload) {
issueNumber = context.payload.pull_request.number;
} else if ("discussion" in context.payload) {
issueNumber = context.payload.discussion.number;
} else {
context.logger.info("Cannot post comment because issue is not found in the payload");
return;
}

await context.octokit.rest.issues.createComment({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
issue_number: issueNumber,
body: await createStructuredMetadataErrorComment(message, context, honoEnv),
});
}

function createStructuredMetadata(className: string | undefined, logReturn: LogReturn) {
const logMessage = logReturn.logMessage;
const metadata = logReturn.metadata;
async function createStructuredMetadataErrorComment(message: LogReturn | Error, context: Context, honoEnv: CloudflareEnvBindings) {
let metadata: Metadata = {};
let logMessage, logTier, callingFnName, headerName;

if (message instanceof Error) {
metadata = {
message: message.message,
name: message.name,
stack: message.stack,
};

callingFnName = message.stack?.split("\n")[2]?.match(/at (\S+)/)?.[1];

} else if (message instanceof LogReturn && message.metadata) {
logMessage = message.logMessage;
logTier = message.logMessage.level; // LogLevel
metadata = message.metadata;

if (metadata.stack || metadata.error) {
metadata.stack = metadata.stack || metadata.error?.stack;
metadata.caller = metadata.caller || metadata.error?.stack?.split("\n")[2]?.match(/at (\S+)/)?.[1];
}

callingFnName = metadata.caller;
} else {
metadata = { ...message };
}

if ("organization" in context.payload) {
headerName = context.payload.organization?.login;
} else if ("repository" in context.payload) {
headerName = context.payload.repository?.owner?.login;
} else if ("installation" in context.payload && "account" in context.payload.installation!) {
// could use their ID here instead as ID is in all installation payloads
headerName = context.payload.installation?.account?.name;
} else {
headerName = context.payload.sender?.login || HEADER_NAME;
}

const workerDetails = await getWorkerDeploymentHash(context, honoEnv);
const workerLogUrl = await getWorkerErrorLogUrl(context, honoEnv);
metadata.worker = { ...workerDetails, logUrl: workerLogUrl };

const jsonPretty = sanitizeMetadata(metadata);
const stack = logReturn.metadata?.stack;
const stackLine = (Array.isArray(stack) ? stack.join("\n") : stack)?.split("\n")[2] ?? "";
const caller = stackLine.match(/at (\S+)/)?.[1] ?? "";
const ubiquityMetadataHeader = `<!-- ${HEADER_NAME} - ${className} - ${caller} - ${metadata?.revision}`;
const ubiquityMetadataHeader = `<!-- UbiquityOS - ${headerName} - ${logTier} - ${context.pluginDeploymentDetails} - ${callingFnName} - ${workerDetails?.versionId.split("-")[0]}`;

let metadataSerialized: string;
const metadataSerializedVisible = ["```json", jsonPretty, "```"].join("\n");
Expand All @@ -43,6 +92,51 @@ function createStructuredMetadata(className: string | undefined, logReturn: LogR
metadataSerialized = metadataSerializedHidden;
}

if (message instanceof Error) {
return [context.logger.error(message.message).logMessage.diff, `\n${metadataSerialized}\n`].join("\n");
}

// Add carriage returns to avoid any formatting issue
return `\n${metadataSerialized}\n`;
return [logMessage?.diff, `\n${metadataSerialized}\n`].join("\n");
}

/**
* These vars will be injected into the worker environment via
* `worker-deploy` action. These are not defined in plugin env schema.
*/
async function getWorkerDeploymentHash(context: Context, honoEnv: CloudflareEnvBindings) {
const accountId = honoEnv.CLOUDFLARE_ACCOUNT_ID;
let scriptName = context.pluginDeploymentDetails;

if (scriptName === "localhost") {
return { versionId: "local", message: "Local development environment" };
}

const scriptUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts/${scriptName}/deployments`;
const response = await fetch(scriptUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${honoEnv.CLOUDFLARE_API_TOKEN}`,
},
});

try {
const data = await response.json() as { result: { deployments: { annotations: { "workers/message": string }, id: string, versions: { version_id: string }[] }[] } };
const deployment = data.result.deployments[0];
const versionId = deployment.versions[0].version_id;
const message = deployment.annotations["workers/message"];
return { versionId, message };
} catch (error) {
context.logger.error(`Error fetching worker deployment hash: ${String(error)}`);
}
}

async function getWorkerErrorLogUrl(context: Context, honoEnv: CloudflareEnvBindings) {
const accountId = honoEnv.CLOUDFLARE_ACCOUNT_ID;
const workerName = context.pluginDeploymentDetails;
const toTime = Date.now() + 60000;
const fromTime = Date.now() - 60000;
const timeParam = encodeURIComponent(`{"type":"absolute","to":${toTime},"from":${fromTime}}`);
return `https://dash.cloudflare.com/${accountId}/workers/services/view/${workerName}/production/observability/logs?granularity=0&time=${timeParam}`;
}
1 change: 1 addition & 0 deletions src/sdk/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export interface Context<TConfig = unknown, TEnv = unknown, TSupportedEvents ext
config: TConfig;
env: TEnv;
logger: Logs;
pluginDeploymentDetails: string;
}
33 changes: 20 additions & 13 deletions src/sdk/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks";
import { TAnySchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import { LOG_LEVEL, LogLevel, LogReturn, Logs } from "@ubiquity-os/ubiquity-os-logger";
import { Hono } from "hono";
import { Env, Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { Manifest } from "../types/manifest";
import { KERNEL_PUBLIC_KEY } from "./constants";
Expand Down Expand Up @@ -31,6 +31,11 @@ const inputSchema = T.Object({
signature: T.String(),
});

export type CloudflareEnvBindings = {
CLOUDFLARE_ACCOUNT_ID: string,
CLOUDFLARE_API_TOKEN: string
}

export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TSupportedEvents>) => Promise<Record<string, unknown> | undefined>,
manifest: Manifest,
Expand All @@ -44,7 +49,8 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
envSchema: options?.envSchema,
};

const app = new Hono();

const app = new Hono<{ Bindings: CloudflareEnvBindings }>();

app.get("/manifest.json", (ctx) => {
return ctx.json(manifest);
Expand Down Expand Up @@ -92,32 +98,33 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
env = ctx.env as TEnv;
}

const workerUrl = new URL(inputs.ref).origin;
let workerName;

if (workerUrl.includes("localhost")) {
workerName = "localhost";
} else {
workerName = `${workerUrl.split("//")[1].split(".")[0]}`;
}

const context: Context<TConfig, TEnv, TSupportedEvents> = {
eventName: inputs.eventName as TSupportedEvents,
payload: inputs.eventPayload,
octokit: new customOctokit({ auth: inputs.authToken }),
config: config,
env: env,
logger: new Logs(pluginOptions.logLevel),
pluginDeploymentDetails: `${workerName}`,
};

try {
const result = await handler(context);
return ctx.json({ stateId: inputs.stateId, output: result });
} catch (error) {
console.error(error);

let loggerError: LogReturn | null;
if (error instanceof Error) {
loggerError = context.logger.error(`Error: ${error}`, { error: error });
} else if (error instanceof LogReturn) {
loggerError = error;
} else {
loggerError = context.logger.error(`Error: ${error}`);
}

const loggerError = error as LogReturn | Error | null;
if (pluginOptions.postCommentOnError && loggerError) {
await postComment(context, loggerError);
await postComment(context, loggerError, honoEnvironment);
Copy link
Author

@Keyrxng Keyrxng Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've passed it as a param here instead of building it into context because the SDK consumer can access anything in context and if they installed their worker into any config other than their own they'd be able to exfiltrate the api token of the partner or us, I think, maybe not.

At least that was my intention, I hope that it holds true.

}

throw new HTTPException(500, { message: "Unexpected error" });
Expand Down