-
Notifications
You must be signed in to change notification settings - Fork 127
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
add ability to capture customer events before library init #436
add ability to capture customer events before library init #436
Conversation
268a3a7
to
a7b96e5
Compare
a7b96e5
to
9d7680b
Compare
src/browser.ts
Outdated
/** | ||
* Represents a buffered method call that occurred before initialization. | ||
*/ | ||
interface PreInitMethodCall<T extends PreInitMethodName = PreInitMethodName> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm unfamiliar with the section that follows the extends
keyword here. What's happening there?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure; it assigns the default type parameter of PreInitMethodName, so PreInitMethodCall can be called without an argument (if desired).
Created a playground!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW, I just pushed a commit that simplified the types a bit!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I didn't know you could do default values in generics, neat!
I think this feature has a lot of merit and I like the improvements, especially that track et al. now returns a promise in all cases. I also like that this allows calling methods on a single object pre and post initialization. I think you could modify this a bit to avoid having to add a bunch of static properties to If we exposed something like a The benefits I see here are:
I haven't gone through the code in depth, but I wanted to get your thoughts on this before digging deeper. |
src/browser.ts
Outdated
// this guard is probably not needed. | ||
if (typeof analytics[method] !== 'function') { | ||
return console.warn( | ||
`invariant error: method call "${method}" does not exist on analytics instance: ${analytics}` | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I'm also struggling to figure out how this could happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was a guard in the existing code! I do think it's an impossible state; if the method doesn't exist, the user will get a reference error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, probably almost impossible state. Maybe if someone uses a snippet with invalid methods, or manually add 💩 to the buffer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, probably almost impossible state. Maybe if someone uses a snippet with invalid methods, or manually add 💩 to the buffer.
In that case, I think they’ve really earned this error message!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's possible that prior to your changes, maybe certain functions wouldn't exist for some edge case, but with all your stricter typings and setup, I think dropping this from future implementations is fine
@chrisradek Thanks for looking this over and writing/voicing your thoughts, and taking the time to chat today! I look forward to talking more about the API and where this fits in with multi-instance and AnalyticsNode. Just to respond to the smaller points (for anyone else reviewing)
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did the first pass, amazing Typescript 🧙♂️ two general comments:
1- If we don't change the implementation, I wonder from usage standpoint we can suggest something like this as well ( in case someone doesn't like to use the class/static methods post-load or to do multi-instance, pretty much gives user the pre-buffer access instantly, and they eventually get access to the instance
after load ):
const [analytics, setAnalytics] = useState(AnalyticsBrowser)
useEffect(() => {
const loadAnalytics = async () => {
let [response] = await AnalyticsBrowser.load({ writeKey })
setAnalytics(response)
AnalyticsBrowser_resetGlobalState()
}
loadAnalytics()
}, [writeKey])
(only concern, not sure how happy Typescript will be with the above ☝️)
2- Probably the only concern is bundle-size, not sure how much we can squeeze down 😬
src/browser.ts
Outdated
// this guard is probably not needed. | ||
if (typeof analytics[method] !== 'function') { | ||
return console.warn( | ||
`invariant error: method call "${method}" does not exist on analytics instance: ${analytics}` | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, probably almost impossible state. Maybe if someone uses a snippet with invalid methods, or manually add 💩 to the buffer.
src/browser.ts
Outdated
if (anon) { | ||
const [, id] = anon | ||
analytics.setAnonymousId(id) | ||
async function flushPreBuffer( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think some opportunity for optimization here (I know it is old code), but given on
and setAnonymousId
aren't async we can filter for both setAnonymousId
and on
in one go, and then call all of them sequentially in a forEach
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good callout -- based on how it was written, I wasn't sure if setAnonymousId had some magic that required that order of operations, so I preserved the original structure.
I made it a bit more functional instead of using a foreach -- it's still O(n) -- let me know your thoughts.
src/browser.ts
Outdated
/** | ||
* Analytics class instance initialized by AnalyticsBrowser.load. | ||
* Reinitializes everytime load is called. | ||
*/ | ||
static instance?: Analytics = undefined | ||
/** | ||
* Analytics context instance initialized by AnalyticsBrowser.load. | ||
*/ | ||
static ctx?: Context = undefined | ||
|
||
static preInitBuffer = new PreInitMethodCallBuffer() | ||
|
||
/** | ||
* Clear the global state. Useful mainly for testing. | ||
*/ | ||
static _resetGlobalState() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Related to: #391
I wish the codebase start to move away from these classes, especially if every method is a static method or variable.
Such practices make the codebase less tree-shakeable and treat the class as a bag of things (a file module) when you can achieve the same doing import * as MyName from '...'
if you care about the aesthetic aspect of it.
Also, it is quite hard to follow, in the FP version you would have clear boundaries with data, state, and behavior.
I made the PR to showcase what it will look like and hopefully you would move there sooner than later. (Sentry@v7 is doing a lot of work on that front if you want validation, your products are really close when it comes to SDKs)
The bigger this class keeps getting, the harder it will be replace it or justify the breaking change, and the Sunk Cost Fallacy will be a thing.
My :2cents:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just chiming in with some context in regards to the Sentry side (https://github.com/getsentry/sentry-javascript)!
Static methods and attributes in classes are something we've stayed away from in our Browser SDK primarily because they just can't get minified. Something like AnalyticsBrowser.trackSubmit()
minifies to aa.trackSubmit()
, vs. for a function -> function trackSubmit()
-> function aa()
. This adds up over time (and suddenly makes function name length actually matter, never a good convo to have in code reviews). As an unrelated note, notice how this also applies for all objects in general, which makes destructuring and object keys all have a cost as well.
This is further exacerbated by the fact that the bundle increases even more when classes are transpiled down to es5 (though this is more an argument to just not use classes at all).
Then there's the general problem of bringing in unrelated logic whenever you import in the class to use static methods, but that is generally solved by keeping classes small + using dependency injection as much as possible (also just not using classes at all).
In general, we've had a lot of gains in moving private/static methods outside of a class (with shims for backwards compatibility until we can make the breaking change). See getsentry/sentry-javascript#4281 as an example!
If you have any questions, please ping, happy to help out :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Related to: #391
I wish the codebase start to move away from these classes, especially if every method is a static method or variable.
Such practices make the codebase less tree-shakeable and treat the class as a bag of things (a file module) when you can achieve the same doing
import * as MyName from '...'
if you care about the aesthetic aspect of it. Also, it is quite hard to follow, in the FP version you would have clear boundaries with data, state, and behavior.I made the PR to showcase what it will look like and hopefully you would move there sooner than later. (Sentry@v7 is doing a lot of work on that front if you want validation, your products are really close when it comes to SDKs)
The bigger this class keeps getting, the harder it will be replace it or justify the breaking change, and the Sunk Cost Fallacy will be a thing.
My :2cents:
Somewhat related — I did some naive comparison testing in our playground using webpack-bundle-analyzer before / after (master vs this branch) and there was essentially no difference in the bundle size.
As I pointed out here: #391 is just a simple change already reducing the bundle size. Is it minimal? yes, most definitely. But for such minimal work that is exactly the same just changing the habits already pays off. As an application developer, I would appreciate every byte you save.
I could continue with the rabbit hole, as @AbhiPrasad pointed out, Sentry already proved some of these concepts with huge benefits.
I can try to do what I did on getsentry/sentry-javascript#4381 if you need more proof that it will eventually compound.
If I am not mistaken Sentry has seen up to 24% reduction and that is not even counting optimizations that you could continue doing, nor counting that it is not 100% of the work that could be done.
Anyway, it could a discussion for later, but as I pointed out "Sunk Cost" may make things harder to change, we are all humans after all.
❤️
As usual, thanks @yordis for the input. I don't think the static methods precludes us from refactoring to a more "functional" and "tree-shaking" friendly interface (per your previous PR).
I can image us eventually refactoring the library to have an entry point that is something like:
export { track } from './track'
export { load } from './load'
// ... more methods
// for legacy users -- it looks like a class, but it's just a POJO
export const AnalyticsBrowser = {
load, // or dynamic import?
track,
// ... more methods
}
And thank you @AbhiPrasad, these is great feedback and I will definitely look @sentry's SDK moving forward.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m available on Sentry’s discord if you ever need anything.
Also apologizes if I came across like I was bikeshedding or causing friction on your PR - totally not the intention, just trying to share some learnings (and hopefully learn some stuff from you folks as well!)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AbhiPrasad no; you weren’t bikeshedding at all, this is great stuff to bring up!
@silesky I've been thinking about this more. My main concerns about treating
I suspect this new strategy will end up being what's used by consumers bundling the library in their application, so I just want to make sure we think about and limit opportunities for devs to accidentally do the wrong thing. On a separate note I do agree with @yordis about not using classes with only static methods since that makes it harder for tools to do some tree-shaking. For example, |
I think it’s common for web developers using a library to initialize something once at the beginning of their application. They can even call it outside of the scope of a component. AnalyticsBrowser.load()
const App = () => … Most database / ORM clients (as default behavior) expect a user to connect once early for the duration of the application. For example, mongoose has a similar API, ditto Apollo Client, etc. This feature has no new behavior in that regard, and will have no affect on any existing users, other than they might be alerted if they're using unsupported behavior.
I’m not so sure about this actually — AnalyticsBrowser.load still returns an instance (even if not referenced) and that code would still be part of the bundle. Somewhat related — I did some naive comparison testing in our playground using webpack-bundle-analyzer before / after (master vs this branch) and there was essentially no difference in the bundle size. My hunch is that, while we might be able to save some bytes here or there, unlike, say, lodash or ramda, the number of functions / methods in this library is quite small but that each public API method has a large amounts of shared logic (e.g. priority queue) that can’t neccessarily be tree-shook away — at least, not without other refactoring and API changes that’s out of the scope of this issue (e.g. moving away from classes completely, moving all the analytics methods to external functions). Minification is a separate issue that I'm sympathetic to -- but for something like method names, it's still a savings on the level of bytes. That can be important, I agree. However, I think we it's something wen iterate on. The bundle size and structure can be improved in the future, but it's much harder to change the public API, so I think it'd be worth it to focus on 'what's the best public API for the majority of our users' that and then let the other "optimization" discussion follow from that. |
Would the analyzer be able to account for tree-shaking when externally consumed? or is it all just using locally built bundles when testing things out? |
I don't 100% know. Unless we did something in our webpack prod build that was getting in the way, the analyzer would be able to tree shake local dependencies just as if they were external. The size will be off, but it works for comparison. If we wanted to be sophisticated, we'd have to test using However, I just did this for fun -- there's not a reason why they would be different. If you're using classes in general, you're not thinking about tree shaking. My intuition says that adding static references to "track" etc are not going to increase bundle size beyond the method signature, since "track" etc are always being referenced in the same context in the instance returned by .load. |
As I pointed out here: #391 is just a simple change already reducing the bundle size. Is it minimal? yes, most definitely. But for such minimal work that is exactly the same just changing the habits already pays off. As an application developer, I would appreciate every byte you save. I could continue with the rabbit hole, as @AbhiPrasad pointed out, Sentry already proved some of these concepts with huge benefits. I can try to do what I did on getsentry/sentry-javascript#4381 if you need more proof that it will eventually compound. If I am not mistaken Sentry has seen up to 24% reduction and that is not even counting optimizations that you could continue doing, nor counting that it is not 100% of the work that could be done. Anyway, it could a discussion for later, but as I pointed out "Sunk Cost" may make things harder to change, we are all humans after all. ❤️ |
For what it's worth, I was thinking more about the bundle we serve via our CDN. Taking what's on It looks like the bundle size for the CDN build with this PR increased ~550 bytes - about 2.2% increase which isn't huge but also for a feature not used by the CDN build. If there are improvements to buffering for the snippet included there then I'd concede the size increase for the CDN build is worth it. |
Actually we do have a bug fix for the CDN that is related to the new code. =) You can repro this super easily on any analytics site: includes fix for snippet users where both an .on event and its callback (e.g on('track', callback) will fire twice if done before init. Part of that bugfix (and general clean up) is the additional normalization for the snippet so we can use a single interface that represents a method call type (the PreInitBuffer class etc). This is just the extra mapping code you always need if you want two separate interfaces to share some logic. Buy yep, I agree we could shave some bytes off if we didn't use static methods. But ultimately , there are much bigger optimizations we can take if we're byte shaving, and I think this should just come down to which API we prefer =) |
I appreciate the optimization and bringing up size budget, and I think that there is work to be done there in the near future. I am new to this library and am not its architect, so I am learning as I go. These conversations are super important for the health of this repo, and I appreciate them. I would like to continuously revisit the structure of this repo and improve it -- not only for reasons of performance and the public API, but readability, coherence, encapsulation, etc. To get us back to this PR: I just did a comparison: Out of the 500 ish bytes that this feature represents, the static methods themselves added 16 bytes to the size of the umd bundle. So really, the references themselves don't take up much space -- though it was worth investigating! The question I want to focus on is "do people like the API? Does it feel natural, how does it fit in with the future roadmap, is it mantainable?". My personal feeling that it does feel natural and it's quite consistent with the API of other libraries and brings us closer to the "load and forget" standalone (CDN) analytics library, and from what I have articulated, I don't think it limits future opportunities for refactoring or limit our roadmap (such as preventing us from making the API more functional, as you proposed in your PR). Stepping into the shoes of the "98%" dev (I've addressed the other 2% in earlier posts), I think it represents a large simplification / boilerplate reduction for my entire app, as well as finally allowing me to capture events that were missed because they occurred before load time. That seems like a big win! |
Leaving some notes,
For example, Making things classes will continue increasing the bundle size. This could be avoided, especially in cases that they are internal to your lib, so you have full control over what is going on inside. Anyway, probably we could (if you want to), continue the conversation somewhere else, focus on getting the features to work first, and then follow up. Make it pretty 💜 |
8d78c6c
to
fe4e3e0
Compare
4072745
to
956a5f8
Compare
956a5f8
to
9c02f90
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good! Nothing serious, just a few suggestions on test cases and some size limit savings.
src/browser.ts
Outdated
/** | ||
* Clear the global state. Useful mainly for testing. | ||
*/ | ||
static _resetGlobalState() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like_resetGlobalState
is only used by tests so we should be able to remove it!
src/analytics-pre-init.ts
Outdated
addDestinationMiddleware = this._createMethod('addDestinationMiddleware') | ||
|
||
private _createMethod<T extends PreInitMethodName>(methodName: T) { | ||
return async ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method doesn't actually use await
, so we can remove the async
keyword and save some bytes (~36 from the final gzipped bundle!)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did not know that there was a cost savings!
If so, we should enable https://eslint.org/docs/rules/require-await
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
src/analytics-pre-init.ts
Outdated
analytics[call.method] as Function | ||
)(...call.args) | ||
|
||
if (result instanceof Promise) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if it would be safer to check if typeof result.then === 'function
. I recall running into issues with instanceof checks on Promises when users shimmed their own Promise library (like bluebird), and we're running in environments that will do that shimming. Even if result
isn't actually a promise, await would still resolve, we'd just defer in that scenario.
src/analytics-pre-init.ts
Outdated
push(...calls: PreInitMethodCall[]): void { | ||
calls.forEach((el) => { | ||
if (this._value[el.method]) { | ||
this._value[el.method]?.push(el) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It makes me sad that TypeScript can't tell that this value has to be an array at this point. Might be the rare time a non-null assertion makes sense :-/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is a refactoring but I think I rolled it back because IIRC it was involved -- It comes down to the MethodMap type being a bit loose. =)
src/analytics-pre-init.ts
Outdated
constructor(calls?: PreInitMethodCall[]) { | ||
if (calls) { | ||
this.push(...calls) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just saw this constructor get added but nothing uses it. Can it be removed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I used it for a test. It's nice to be able to initialize inside the constructor, but I can remove.
const newPromise = ajsBuffered.catch((reason) => { | ||
expect(reason).toBe(errMsg) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does the same work if we're using await
e.g. await AnalyticsBrowser.load({ writeKey: 'invalid' })
- does this throw an error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep. For example, that case is also covered here: https://github.com/segmentio/analytics-next/pull/436/files/f1042bc17a4f13b7335433390d1892e77b630aed#diff-a3756d7d2cc9639c2f00f5871656d78a5aa10fddd6ba6bdae04447a577140d4aR165 (and probably some other places)
I think I only preferred to test promise syntax here in order to be explicit that we are talking about thenable methods, since try/catch can be used in other contexts. Wasn't necessary to do it one way or another... Technically, if an object is asserted to be a thenable as it is here (it has a .then
and optional .catch
method), I wouldn't necessarily try to test both syntax forms, as that's part of the spec.
1e1db10
to
f785c64
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whoo! Looks good to me!
70deee9
to
95ef70c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀🚀🚀
2930c9b
to
37cd27a
Compare
Any user method calls to .track / .identify / etc before analytics will be cached until analytics is finished loading.
One of the benefits of the snippet is that If a user calls analytics.track, we capture events for that user and then flush those events once the library has finished initializing.
However, when using the npm library, a user must currently wait for a .load in order to capture .track calls. This leads to
additional boilerplate as well as scenarios where user events might not get captured, as initialization can take a while. This feature offers a synchronous API that will cache events and then dispatch them when analytics finishes loading.
Other notes:
.on
event and itscallback
(e.gon('track', callback)
will fire twice if done before init.This PR allows you to do
This change is backwards-compatible with the previous API.
Awaiting on AnalyticsBrowser will still return you [Analytics, Context] (like before).