Skip to content

Commit c8df722

Browse files
committed
Improve node-sdk fallback logging
1 parent 32d0ecf commit c8df722

3 files changed

Lines changed: 197 additions & 47 deletions

File tree

packages/node-sdk/src/batch-buffer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export default class BatchBuffer<T> {
7979
count: flushingBuffer.length,
8080
});
8181
} catch (error) {
82-
this.logger?.error("flush of buffered items failed; discarding items", {
82+
this.logger?.warn("flush of buffered items failed; discarding items", {
8383
error,
8484
count: flushingBuffer.length,
8585
});

packages/node-sdk/src/client.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,38 @@ function createFlagsFallbackSnapshot(
175175
};
176176
}
177177

178+
function formatFlagsFallbackAge(savedAt: string): string | undefined {
179+
const savedAtMs = Date.parse(savedAt);
180+
if (!Number.isFinite(savedAtMs)) {
181+
return undefined;
182+
}
183+
184+
const ageMs = Math.max(0, Date.now() - savedAtMs);
185+
const minuteMs = 60_000;
186+
const hourMs = 60 * minuteMs;
187+
const dayMs = 24 * hourMs;
188+
189+
if (ageMs < minuteMs) {
190+
return "<1m";
191+
}
192+
193+
if (ageMs < hourMs) {
194+
return `${Math.floor(ageMs / minuteMs)}m`;
195+
}
196+
197+
if (ageMs < dayMs) {
198+
return `${Math.floor(ageMs / hourMs)}h`;
199+
}
200+
201+
return `${Math.floor(ageMs / dayMs)}d`;
202+
}
203+
204+
function createErrorWithCause(message: string, cause: unknown): Error {
205+
const error = new Error(message) as Error & { cause?: unknown };
206+
error.cause = cause;
207+
return error;
208+
}
209+
178210
/**
179211
* The SDK client.
180212
*
@@ -427,7 +459,6 @@ export class ReflagClient {
427459
this._config.flagsFetchRetries,
428460
);
429461
if (!isObject(res) || !Array.isArray(res?.features)) {
430-
this.logger.warn("flags cache: invalid response", res);
431462
return await this.loadFlagsFallbackDefinitions();
432463
}
433464

@@ -468,6 +499,9 @@ export class ReflagClient {
468499
);
469500

470501
if (!snapshot) {
502+
this.logger.warn(
503+
"remote flags unavailable, no fallback flags found in flagsFallbackProvider",
504+
);
471505
return undefined;
472506
}
473507

@@ -476,9 +510,12 @@ export class ReflagClient {
476510
return undefined;
477511
}
478512

479-
this.logger.warn("using flag definitions from flagsFallbackProvider", {
480-
savedAt: snapshot.savedAt,
481-
});
513+
const fallbackAge = formatFlagsFallbackAge(snapshot.savedAt);
514+
this.logger.warn(
515+
fallbackAge
516+
? `remote flags unavailable, using fallback flags fetched ${fallbackAge} ago (${snapshot.savedAt})`
517+
: `remote flags unavailable, using fallback flags (${snapshot.savedAt})`,
518+
);
482519

483520
return compileFlagDefinitions(snapshot.flags);
484521
} catch (error) {
@@ -977,8 +1014,6 @@ export class ReflagClient {
9771014
* @param path - The path to send the request to.
9781015
* @param body - The body of the request.
9791016
*
980-
* @returns A boolean indicating if the request was successful.
981-
*
9821017
* @throws An error if the path or body is invalid.
9831018
**/
9841019
private async post<TBody>(path: string, body: TBody) {
@@ -996,16 +1031,16 @@ export class ReflagClient {
9961031
this.logger.debug(`post request to "${url}"`, response);
9971032

9981033
if (!response.ok || !isObject(response.body) || !response.body.success) {
999-
this.logger.warn(
1034+
throw createErrorWithCause(
10001035
`invalid response received from server for "${url}"`,
10011036
JSON.stringify(response),
10021037
);
1003-
return false;
10041038
}
1005-
return true;
10061039
} catch (error) {
1007-
this.logger.error(`post request to "${url}" failed with error`, error);
1008-
return false;
1040+
throw createErrorWithCause(
1041+
`post request to "${url}" failed with error`,
1042+
error,
1043+
);
10091044
}
10101045
}
10111046

@@ -1051,7 +1086,7 @@ export class ReflagClient {
10511086
10000,
10521087
);
10531088
} catch (error) {
1054-
this.logger.error(
1089+
this.logger.debug(
10551090
`get request to "${path}" failed with error after ${retries} retries`,
10561091
error,
10571092
);
@@ -1072,9 +1107,10 @@ export class ReflagClient {
10721107
"events must be a non-empty array",
10731108
);
10741109

1075-
const sent = await this.post("bulk", events);
1076-
if (!sent) {
1077-
throw new Error("Failed to send bulk events");
1110+
try {
1111+
await this.post("bulk", events);
1112+
} catch (error) {
1113+
throw createErrorWithCause("failed to send bulk events", error);
10781114
}
10791115
}
10801116

packages/node-sdk/test/client.test.ts

Lines changed: 145 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -593,9 +593,14 @@ describe("ReflagClient", () => {
593593
await client.updateUser(user.id);
594594
await client.flush();
595595

596-
expect(logger.error).toHaveBeenCalledWith(
597-
expect.stringMatching("post request to .* failed with error"),
598-
error,
596+
expect(logger.warn).toHaveBeenCalledWith(
597+
"flush of buffered items failed; discarding items",
598+
expect.objectContaining({
599+
count: 1,
600+
error: expect.objectContaining({
601+
message: "failed to send bulk events",
602+
}),
603+
}),
599604
);
600605
});
601606

@@ -608,9 +613,15 @@ describe("ReflagClient", () => {
608613
await client.flush();
609614

610615
expect(logger.warn).toHaveBeenCalledWith(
611-
expect.stringMatching("invalid response received from server for"),
612-
JSON.stringify(response),
616+
"flush of buffered items failed; discarding items",
617+
expect.objectContaining({
618+
count: 1,
619+
error: expect.objectContaining({
620+
message: "failed to send bulk events",
621+
}),
622+
}),
613623
);
624+
expect(logger.error).not.toHaveBeenCalled();
614625
});
615626

616627
it("should throw an error if opts are not valid or the user is not set", async () => {
@@ -682,9 +693,14 @@ describe("ReflagClient", () => {
682693
await client.updateCompany(company.id, {});
683694
await client.flush();
684695

685-
expect(logger.error).toHaveBeenCalledWith(
686-
expect.stringMatching("post request to .* failed with error"),
687-
error,
696+
expect(logger.warn).toHaveBeenCalledWith(
697+
"flush of buffered items failed; discarding items",
698+
expect.objectContaining({
699+
count: 1,
700+
error: expect.objectContaining({
701+
message: "failed to send bulk events",
702+
}),
703+
}),
688704
);
689705
});
690706

@@ -700,9 +716,15 @@ describe("ReflagClient", () => {
700716
await client.flush();
701717

702718
expect(logger.warn).toHaveBeenCalledWith(
703-
expect.stringMatching("invalid response received from server for"),
704-
JSON.stringify(response),
719+
"flush of buffered items failed; discarding items",
720+
expect.objectContaining({
721+
count: 1,
722+
error: expect.objectContaining({
723+
message: "failed to send bulk events",
724+
}),
725+
}),
705726
);
727+
expect(logger.error).not.toHaveBeenCalled();
706728
});
707729

708730
it("should throw an error if company is not valid", async () => {
@@ -819,9 +841,14 @@ describe("ReflagClient", () => {
819841
await client.bindClient({ user }).track(event.event);
820842
await client.flush();
821843

822-
expect(logger.error).toHaveBeenCalledWith(
823-
expect.stringMatching("post request to .* failed with error"),
824-
error,
844+
expect(logger.warn).toHaveBeenCalledWith(
845+
"flush of buffered items failed; discarding items",
846+
expect.objectContaining({
847+
count: 2,
848+
error: expect.objectContaining({
849+
message: "failed to send bulk events",
850+
}),
851+
}),
825852
);
826853
});
827854

@@ -837,9 +864,15 @@ describe("ReflagClient", () => {
837864
await client.flush();
838865

839866
expect(logger.warn).toHaveBeenCalledWith(
840-
expect.stringMatching("invalid response received from server for "),
841-
JSON.stringify(response),
867+
"flush of buffered items failed; discarding items",
868+
expect.objectContaining({
869+
count: 2,
870+
error: expect.objectContaining({
871+
message: "failed to send bulk events",
872+
}),
873+
}),
842874
);
875+
expect(logger.error).not.toHaveBeenCalled();
843876
});
844877

845878
it("should log if user is not set", async () => {
@@ -957,10 +990,11 @@ describe("ReflagClient", () => {
957990
});
958991

959992
it("should load flag definitions from flagsFallbackProvider when live fetch fails", async () => {
993+
const savedAt = "2026-03-09T00:00:00.000Z";
960994
const flagsFallbackProvider: FlagsFallbackProvider = {
961995
load: vi.fn().mockResolvedValue({
962996
version: 1,
963-
savedAt: "2026-03-09T00:00:00.000Z",
997+
savedAt,
964998
flags: flagDefinitions.features,
965999
}),
9661000
save: vi.fn(),
@@ -971,6 +1005,90 @@ describe("ReflagClient", () => {
9711005
const client = new ReflagClient({
9721006
...validOptions,
9731007
flagsFallbackProvider,
1008+
flagsFetchRetries: 0,
1009+
});
1010+
1011+
vi.useFakeTimers();
1012+
vi.setSystemTime(new Date("2026-03-09T00:20:00.000Z"));
1013+
try {
1014+
await client.initialize();
1015+
1016+
expect(flagsFallbackProvider.load).toHaveBeenCalledWith(
1017+
expect.objectContaining({
1018+
secretKeyHash: expect.any(String),
1019+
}),
1020+
);
1021+
expect(logger.warn).toHaveBeenCalledTimes(1);
1022+
expect(logger.warn).toHaveBeenCalledWith(
1023+
`remote flags unavailable, using fallback flags fetched 20m ago (${savedAt})`,
1024+
);
1025+
1026+
expect(
1027+
client.getFlag({ company, user, other: otherContext }, "flag1"),
1028+
).toStrictEqual({
1029+
key: "flag1",
1030+
isEnabled: true,
1031+
config: {
1032+
key: "config-1",
1033+
payload: { something: "else" },
1034+
},
1035+
track: expect.any(Function),
1036+
});
1037+
} finally {
1038+
vi.useRealTimers();
1039+
}
1040+
});
1041+
1042+
it("should log remote flag fetch failures at debug level when using fallback definitions", async () => {
1043+
const error = new Error("fetch failed");
1044+
const savedAt = "2026-03-09T00:00:00.000Z";
1045+
const flagsFallbackProvider: FlagsFallbackProvider = {
1046+
load: vi.fn().mockResolvedValue({
1047+
version: 1,
1048+
savedAt,
1049+
flags: flagDefinitions.features,
1050+
}),
1051+
save: vi.fn(),
1052+
};
1053+
1054+
httpClient.get.mockRejectedValue(error);
1055+
1056+
const client = new ReflagClient({
1057+
...validOptions,
1058+
flagsFallbackProvider,
1059+
flagsFetchRetries: 0,
1060+
});
1061+
1062+
vi.useFakeTimers();
1063+
vi.setSystemTime(new Date("2026-03-09T00:20:00.000Z"));
1064+
try {
1065+
await client.initialize();
1066+
1067+
expect(logger.debug).toHaveBeenCalledWith(
1068+
'get request to "features" failed with error after 0 retries',
1069+
error,
1070+
);
1071+
expect(logger.warn).toHaveBeenCalledWith(
1072+
`remote flags unavailable, using fallback flags fetched 20m ago (${savedAt})`,
1073+
);
1074+
expect(logger.error).not.toHaveBeenCalled();
1075+
} finally {
1076+
vi.useRealTimers();
1077+
}
1078+
});
1079+
1080+
it("should warn when live fetch fails and flagsFallbackProvider has no saved snapshot", async () => {
1081+
const flagsFallbackProvider: FlagsFallbackProvider = {
1082+
load: vi.fn().mockResolvedValue(undefined),
1083+
save: vi.fn(),
1084+
};
1085+
1086+
httpClient.get.mockResolvedValue({ success: false });
1087+
1088+
const client = new ReflagClient({
1089+
...validOptions,
1090+
flagsFallbackProvider,
1091+
flagsFetchRetries: 0,
9741092
});
9751093

9761094
await client.initialize();
@@ -980,18 +1098,9 @@ describe("ReflagClient", () => {
9801098
secretKeyHash: expect.any(String),
9811099
}),
9821100
);
983-
984-
expect(
985-
client.getFlag({ company, user, other: otherContext }, "flag1"),
986-
).toStrictEqual({
987-
key: "flag1",
988-
isEnabled: true,
989-
config: {
990-
key: "config-1",
991-
payload: { something: "else" },
992-
},
993-
track: expect.any(Function),
994-
});
1101+
expect(logger.warn).toHaveBeenCalledWith(
1102+
"remote flags unavailable, no fallback flags found in flagsFallbackProvider",
1103+
);
9951104
});
9961105

9971106
it("should save fetched flag definitions to flagsFallbackProvider", async () => {
@@ -1856,9 +1965,14 @@ describe("ReflagClient", () => {
18561965

18571966
await client.flush();
18581967

1859-
expect(logger.error).toHaveBeenCalledWith(
1860-
expect.stringMatching("post request .* failed with error"),
1861-
expect.any(Error),
1968+
expect(logger.warn).toHaveBeenCalledWith(
1969+
"flush of buffered items failed; discarding items",
1970+
expect.objectContaining({
1971+
count: 2,
1972+
error: expect.objectContaining({
1973+
message: "failed to send bulk events",
1974+
}),
1975+
}),
18621976
);
18631977
});
18641978

0 commit comments

Comments
 (0)