Skip to content
Draft
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
1 change: 1 addition & 0 deletions assertTagging.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export default {
assertionFunctions: {
"assert": 1,
"fail": 0,
"failWithTelemetry": 0,
},
};
65 changes: 54 additions & 11 deletions packages/common/core-utils/src/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,67 @@ export function assert(
debugMessageBuilder?: () => string,
): asserts condition {
if (!condition) {
failPrivate(message, debugMessageBuilder);
const error = new AssertionError(message);
prepareAssertionError(error, debugMessageBuilder);
return error.throw();
}
}

/**
* {@link fail}'s implementation, but extracted to avoid assert tagging trying to tag the use of it in `assert`.
* An error which indicates a bug in the Fluid Framework codebase.
* @privateRemarks
* This can't use the error types in telemetry-utils, since that would create a circular dependency between the packages.
*
* This should not be constructed directly, instead use an API like {@link assert} or {@link fail} which is registered for assert message tagging.
* @internal
*/
function failPrivate(message: string | number, debugMessageBuilder?: () => string): never {
let messageString =
typeof message === "number" ? `0x${message.toString(16).padStart(3, "0")}` : message;
export class AssertionError extends Error {
/**
* A constant string, which might be a string literal or hex encoding of a numeric code which corresponds to string literal.
* @remarks
* As this is a constant from the Fluid Framework codebase, it should contain no variable information, and be safe to log.
*/
public readonly constantMessage: string;

public constructor(
/**
* A constant string, or numeric code which corresponds to a constant string, which can be used to identify the assertion location in the codebase.
* Thus must contain no variable information, and should be a string literal or numeric code.
* @remarks
* As this is a constant from the Fluid Framework codebase, it should contain no variable information, and be safe to log.
*/
message: string | number,
) {
const constantMessage =
typeof message === "number" ? `0x${message.toString(16).padStart(3, "0")}` : message;
super(constantMessage);
this.constantMessage = constantMessage;
this.name = "AssertionError";
// We do not call onAssertionError(this) here so that subclasses can provide additional information in their constructor before the error is reported.
}

/**
* Throw this error, after reporting it to any registered assertion failure handlers.
*/
public throw(): never {
onAssertionError(this);
throw this;
}
}

function prepareAssertionError(
error: AssertionError,
debugMessageBuilder?: () => string,
): void {
skipInProduction(() => {
if (debugMessageBuilder !== undefined) {
messageString = `${messageString}\nDebug Message: ${debugMessageBuilder()}`;
Object.assign(error, {
message: `${error.constantMessage}\nDebug Message: ${debugMessageBuilder()}`,
});
}
// Using console.log instead of console.error or console.warn since the latter two may break downstream users.
console.log(`Bug in Fluid Framework: Failed Assertion: ${messageString}`);
console.log(`Bug in Fluid Framework: Failed Assertion: ${error.message}`);
});
const error = new Error(messageString);
onAssertionError(error);
throw error;
}

/**
Expand All @@ -75,7 +116,9 @@ function failPrivate(message: string | number, debugMessageBuilder?: () => strin
* @internal
*/
export function fail(message: string | number, debugMessageBuilder?: () => string): never {
failPrivate(message, debugMessageBuilder);
const error = new AssertionError(message);
prepareAssertionError(error, debugMessageBuilder);
return error.throw();
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/common/core-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

export {
assert,
AssertionError,
appendDebugMessage,
fail,
debugAssert,
Expand Down
39 changes: 38 additions & 1 deletion packages/utils/telemetry-utils/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
FluidErrorTypes,
type IGenericError,
type ILayerIncompatibilityError,
type ILoggingError,
type IUsageError,
} from "@fluidframework/core-interfaces/internal";
import { AssertionError } from "@fluidframework/core-utils/internal";
import type { ISequencedDocumentMessage } from "@fluidframework/driver-definitions/internal";

import {
Expand All @@ -20,7 +22,7 @@ import {
wrapError,
} from "./errorLogging.js";
import type { IFluidErrorBase } from "./fluidErrorBase.js";
import type { ITelemetryPropertiesExt } from "./telemetryTypes.js";
import type { ITelemetryPropertiesExt, TelemetryLoggerExt } from "./telemetryTypes.js";

/**
* A subset of `ISequencedDocumentMessage` properties that are safe to log for telemetry.
Expand Down Expand Up @@ -184,6 +186,41 @@ export class GenericError extends LoggingError implements IGenericError, IFluidE
}
}

class TelemetryAssertionError extends AssertionError implements ILoggingError {
public constructor(
message: string | number,
private readonly telemetryProps: ITelemetryBaseProperties,
private readonly logger?: TelemetryLoggerExt,
) {
super(message);
}

public getTelemetryProperties(): ITelemetryBaseProperties {
return { ...this.telemetryProps, constantMessage: this.constantMessage };
}

public override throw(): never {
this.logger?.sendErrorEvent({ eventName: "AssertionError" }, this);
return super.throw();
}
}

/**
* Unconditionally throws an assertion error with optional telemetry properties and logging.
*
* @param message - A constant message or tagged numeric code identifying the assertion.
* @param logger - Optional logger used to report the assertion before it is thrown.
* @param props - Telemetry properties to include when the error is logged.
* @internal
*/
export function failWithTelemetry(
message: string | number,
logger?: TelemetryLoggerExt,
props: ITelemetryBaseProperties = {},
): never {
return new TelemetryAssertionError(message, props, logger).throw();
}

/**
* Error indicating an API is being used improperly resulting in an invalid operation.
*
Expand Down
16 changes: 14 additions & 2 deletions packages/utils/telemetry-utils/src/errorLogging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function extractLogSafeErrorProperties(
message: string;
errorType?: string | undefined;
stack?: string | undefined;
constantMessage?: string | undefined;
} {
const removeMessageFromStack = (stack: string, errorName?: string): string => {
if (!sanitizeStack) {
Expand All @@ -52,12 +53,19 @@ export function extractLogSafeErrorProperties(
? (error as Error).message
: String(error);

const safeProps: { message: string; errorType?: string; stack?: string } = {
const safeProps: {
message: string;
errorType?: string;
stack?: string;
constantMessage?: string;
} = {
message,
};

if (isRegularObject(error)) {
const { errorType, stack, name } = error as Partial<IFluidErrorBase>;
const { errorType, stack, name, constantMessage } = error as Partial<
IFluidErrorBase & { constantMessage: string }
>;

if (typeof errorType === "string") {
safeProps.errorType = errorType;
Expand All @@ -67,6 +75,10 @@ export function extractLogSafeErrorProperties(
const errorName = typeof name === "string" ? name : undefined;
safeProps.stack = removeMessageFromStack(stack, errorName);
}

if (typeof constantMessage === "string") {
safeProps.constantMessage = constantMessage;
}
}

return safeProps;
Expand Down
1 change: 1 addition & 0 deletions packages/utils/telemetry-utils/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
DataCorruptionError,
DataProcessingError,
extractSafePropertiesFromMessage,
failWithTelemetry,
GenericError,
UsageError,
validatePrecondition,
Expand Down
3 changes: 2 additions & 1 deletion packages/utils/telemetry-utils/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,15 @@ export abstract class TelemetryLogger implements TelemetryLoggerExt {
error: unknown,
fetchStack: boolean,
): void {
const { message, errorType, stack } = extractLogSafeErrorProperties(
const { message, errorType, stack, constantMessage } = extractLogSafeErrorProperties(
error,
true /* sanitizeStack */,
);
// First, copy over error message, stack, and errorType directly (overwrite if present on event)
event.stack = stack;
event.error = message; // Note that the error message goes on the 'error' field
event.errorType = errorType;
event.constantMessage = constantMessage;

if (isILoggingError(error)) {
// Add any other telemetry properties from the LoggingError
Expand Down
47 changes: 46 additions & 1 deletion packages/utils/telemetry-utils/src/test/error.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,57 @@
import { strict as assert } from "node:assert";

import { FluidErrorTypes } from "@fluidframework/core-interfaces/internal";
import { AssertionError } from "@fluidframework/core-utils/internal";

import { DataCorruptionError, DataProcessingError, UsageError } from "../error.js";
import {
DataCorruptionError,
DataProcessingError,
failWithTelemetry,
UsageError,
} from "../error.js";
import { LoggingError, isILoggingError, normalizeError } from "../errorLogging.js";
import { isFluidError } from "../fluidErrorBase.js";
import { MockLogger } from "../mockLogger.js";

describe("Errors", () => {
describe("failWithTelemetry", () => {
it("throws an AssertionError with telemetry properties", () => {
let actual: unknown;
try {
failWithTelemetry(0xabc, undefined, { safeProperty: 1 });
} catch (error: unknown) {
actual = error;
}

assert(actual instanceof AssertionError);
assert.equal(actual.message, "0xabc");
assert.equal(actual.constantMessage, "0xabc");
assert(isILoggingError(actual));
assert.deepEqual(actual.getTelemetryProperties(), {
safeProperty: 1,
constantMessage: "0xabc",
});
});

it("logs the assertion when given a logger", () => {
const mockLogger = new MockLogger();
assert.throws(() =>
failWithTelemetry("constant message", mockLogger.toTelemetryLogger(), {
safeProperty: 1,
}),
);

mockLogger.assertMatch([
{
eventName: "AssertionError",
error: "constant message",
constantMessage: "constant message",
safeProperty: 1,
},
]);
});
});

describe("DataCorruptionError.create", () => {
it("Should yield a DataCorruptionError", () => {
const dce = DataCorruptionError.create("Some message", "someCodepath", undefined, {
Expand Down
33 changes: 33 additions & 0 deletions packages/utils/telemetry-utils/src/test/errorLogging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import type {
import sinon from "sinon";
import { v4 as uuid } from "uuid";

import {
AssertionError,
assert as coreAssert,
fail,
} from "@fluidframework/core-utils/internal";

import {
type IFluidErrorAnnotations,
LoggingError,
Expand Down Expand Up @@ -64,6 +70,33 @@ describe("Error Logging", () => {
TelemetryLogger.prepareErrorObject(event, null, false);
assert.strictEqual(event.error, "null", "null should work");
});
it("adds the constant message from an AssertionError", () => {
const event = freshEvent();
const error = new AssertionError("constant message");
TelemetryLogger.prepareErrorObject(event, error, false);

assert.strictEqual(event.error, "constant message");
assert.strictEqual(event.constantMessage, "constant message");
});
it("adds the constant message from assert and fail errors", () => {
for (const throwError of [
(): void => coreAssert(false, 0xabc, () => "dynamic assert details"),
(): void => fail(0xdef, () => "dynamic fail details"),
]) {
let error: unknown;
try {
throwError();
} catch (caught: unknown) {
error = caught;
}
assert(error instanceof AssertionError);

const event = freshEvent();
TelemetryLogger.prepareErrorObject(event, error, false);
assert.strictEqual(event.constantMessage, error.constantMessage);
assert.doesNotMatch(event.constantMessage as string, /dynamic/);
}
});
it("stack and message added to event (stack should exclude message)", () => {
const event = freshEvent();
const error = new Error("boom");
Expand Down
Loading