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
86 changes: 86 additions & 0 deletions spec/v1/providers/analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,92 @@ describe("Analytics Functions", () => {
analyticsSpecInput.data
);
});

it("should handle null and missing user property values without throwing", () => {
const cloudFunction = analytics
.event("app_remove")
.onLog((data: analytics.AnalyticsEvent) => data);

const event: Event = {
data: {
eventDim: [
{
name: "app_remove",
params: {},
date: "20240114",
timestampMicros: "1705257600000000",
},
],
userDim: {
userProperties: {
// Invalid properties that should be filtered out:
null_property: null,
value_null: {
value: null,
},
value_undefined: {
value: undefined,
},
empty_object: {},
value_empty_object: {
value: {},
},
// Valid properties that should be kept:
valid_string: {
value: {
stringValue: "test",
},
setTimestampUsec: "1486076786090987",
},
valid_empty_string: {
value: {
stringValue: "",
},
setTimestampUsec: "1486076786090987",
},
valid_zero: {
value: {
intValue: "0",
},
setTimestampUsec: "1486076786090987",
},
},
},
},
context: {
eventId: "70172329041928",
timestamp: "2018-04-09T07:56:12.975Z",
eventType: "providers/google.firebase.analytics/eventTypes/event.log",
resource: {
service: "app-measurement.com",
name: "projects/project1/events/app_remove",
},
},
};

return expect(cloudFunction(event.data, event.context)).to.eventually.deep.equal({
reportingDate: "20240114",
name: "app_remove",
params: {},
logTime: "2024-01-14T18:40:00.000Z",
user: {
userProperties: {
valid_string: {
value: "test",
setTime: "2017-02-02T23:06:26.090Z",
},
valid_empty_string: {
value: "",
setTime: "2017-02-02T23:06:26.090Z",
},
valid_zero: {
value: "0",
setTime: "2017-02-02T23:06:26.090Z",
},
},
},
});
});
});
});

Expand Down
47 changes: 42 additions & 5 deletions src/v1/providers/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,34 @@ export class UserDimensions {
// The following fields do need transformations of some sort.
copyTimestampToString(wireFormat, this, "firstOpenTimestampMicros", "firstOpenTime");
this.userProperties = {}; // With no entries in the wire format, present an empty (as opposed to absent) map.
copyField(wireFormat, this, "userProperties", (r) => {
const entries = Object.entries(r).map(([k, v]) => [k, new UserPropertyValue(v)]);
copyField(wireFormat, this, "userProperties", (r: unknown) => {
const entries = Object.entries(r as Record<string, unknown>)
.filter(([, v]) => {
if (v === null || v === undefined) {
return false;
}
if (typeof v !== "object") {
return false;
}
const vObj = v as Record<string, unknown>;
// Filter out empty objects
if (Object.keys(vObj).length === 0) {
return false;
}
// Filter if 'value' property exists but is null/undefined/empty object
if ("value" in vObj) {
const value = vObj.value;
if (
value === null ||
value === undefined ||
(typeof value === "object" && value !== null && Object.keys(value).length === 0)
) {
return false;
}
}
return true;
})
Comment on lines +184 to +208
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The filtering logic here is comprehensive but also quite complex and spread across multiple conditional statements, which could affect readability and future maintenance. To make the logic clearer and more self-documenting, you could refactor it slightly by grouping related checks and adding comments to explain each validation step.

        .filter(([, v]) => {
          // Property must be a non-empty object.
          if (v == null || typeof v !== "object") {
            return false;
          }
          if (Object.keys(v).length === 0) {
            return false;
          }

          // If 'value' field exists, it must not be null, undefined, or an empty object.
          if ("value" in v) {
            const value = (v as { value: unknown }).value;
            if (value == null || (typeof value === "object" && value !== null && Object.keys(value).length === 0)) {
              return false;
            }
          }

          return true;
        })

.map(([k, v]) => [k, new UserPropertyValue(v)]);
return Object.fromEntries(entries);
});
copyField(wireFormat, this, "bundleInfo", (r) => new ExportBundleInfo(r) as any);
Expand Down Expand Up @@ -454,9 +480,20 @@ function mapKeys<Obj, Transform extends (key: keyof Obj) => any>(
// method always returns a string, similarly to avoid loss of precision, unlike the less-conservative
// 'unwrapValue' method just below.
/** @hidden */
function unwrapValueAsString(wrapped: any): string {
const key: string = Object.keys(wrapped)[0];
return wrapped[key].toString();
function unwrapValueAsString(wrapped: unknown): string {
if (!wrapped || typeof wrapped !== "object") {
return "";
}
const keys = Object.keys(wrapped);
if (keys.length === 0) {
return "";
}
const key: string = keys[0];
const value = (wrapped as Record<string, unknown>)[key];
if (value === null || value === undefined) {
return "";
}
return value.toString();
}

// Ditto as the method above, but returning the values in the idiomatic JavaScript type (string for strings,
Expand Down
Loading