DO_NOT_MERGE: add instrumentation - #1051
Conversation
RangerMauve
left a comment
There was a problem hiding this comment.
Looks good so far. I'm a little bit concerned about the overhead adding callbacks everywhere adds. We'd be allocating more to the heap and adding extra stuff to the scope chain for every single call which IMO will add up on hot code paths.
Is using the tracer span and adding the .end() inside a finally block out of the question?
| }), | ||
| } | ||
|
|
||
| global[GLOBAL_INSTRUMENTATION_ACCESSOR_KEY] = globalValue |
There was a problem hiding this comment.
| global[GLOBAL_INSTRUMENTATION_ACCESSOR_KEY] = globalValue | |
| globalThis[GLOBAL_INSTRUMENTATION_ACCESSOR_KEY] = globalValue |
I prefer globalThis to global since it's the standard and is more future proof
Yeah, I wonder what overhead that will add, even when the callback is a noop. There are very few places where we would add this though in loop where it is called multiple times, so given the overall time that the functions we call take, does this really add much overhead? In terms of creating an inactive span and calling I think the bigger overhead is for actually capturing and sending these spans / instrumentation, because I think in any code path we would only have 1-5 wrapping callbacks, which I don't think relatively would have a big effect. The actual "non-noop" instrumentation overhead may well be significant though, and to reduce that impact we would use a |
What mechanism do they use for this feature? I'd be surprised if we couldn't set this sort of chaining without needing callbacks.
Yeah I guess we don't have the instrumentation to measure this in the first place. 😅 I just worry we could go to a "death by a thousand paper cuts" situation if we don't consider performance stuff from the get go. Especially if efforts like the new HTTP APIs in comapeo cloud need to scale more if they get heavy usage. Though maybe we can revisit this later once we can get some flame graphs and see what the memory usage / GC overhead is in normal situations. |
Otel uses AsyncLocalStorage. If we don't do this within a callback, then there is no way of knowing when there is more than one async operation at the same time, which child belongs to which parent. We could pass spans around ourselves, but that would be a lot of work and maintenance, and require adding a span option to many method calls.
I think it will only really become an issue with comapeo cloud, rather than in the app, as long as we avoid adding it to "hot" code paths, e.g. avoid adding it to an operation which is carried out multiple times. However, also, as I understand it, the standard approach, which I believe a lot of production apps use, is to instrument every single DB transaction, which would have a lot more overhead than what we are planning (when I instrumented better-sqlite3 it generated 245 spans for the create project operation, which I think it too much for our needs). I think observing bugs and debugging issues is a priority right now, so I think it makes sense to add this, and then analyse the impact and possibly remove it later. It is just frustrating that decorators aren't finalized yet, because if they were then we could implement most of this with decorators for methods we want to trace, which would be easy to remove. We could consider adding a build-step to transform decorators, but I don't like that. We could even get fancy and add decorators in comments, and add our own build-step to transform the comments, but I haven't seen prior examples of that. The other possibility is that we add some kind of constructor function that wraps class methods in spans, and only do that when we explicitly turn it on. |
Agreed
I think our API surface is small enough that adding the current approach won't be too bad. Worst case we could even run a transform over the AST to remove trace code if it's repetitive enough. Another option might be to wrap objects in a Proxy which instruments all api calls. That might also have a performance hit but not sure if it's too much worse. |
|
Unfortunately using a Proxy as in 1a9793f is not going to work, because for correct behaviour it's necessary to pass the An alternative approach would be to mutate / override the original methods in the constructor, e.g. const createProjectOrig = this.createProject
this.createProject = instrument(createProjectOrig)However in the end I think it's clearer to just directly write the instrumentation in each method, as in the original commit dc83e6c - this has the disadvantage of a larger commit for the changes, because of the indentation within the callback, but this can be ignored when reviewing by selecting the "ignore white space" option in the github diff view and in |
This reverts commit 1a9793f.
This is a proof-of-concept for adding OpenTelemetry instrumentation to CoMapeo Core. The main reason for this is to get detailed traces for debugging and performance monitoring for the internal workings. The reason for defining instrumentation as a global variable, rather than mutating a global singleton, is so that we can eventually publish
@comapeo/core-instrumentationas a separate module, so that by default the OpenTelemetry dependencies are not included in this module. This approach is largely copied from how Prisma implements instrumentation.This is a first-step to setting up distributed tracing, which will give us insight into how different components are interacting with each other. For distributed tracing between the backend and frontend of apps, because the boundary between the components is in the app (not in this library), the context should be propagated in the RPC that we setup between the front-end and back-end in the app, and we can create a parent span in the backend of the mobile app. Distributed tracing of p2p RPC will need to be implemented here in core, and will require adding a
trace_parentfield to the RPC messages.I have used OpenTelemetry here, rather than Sentry, which we use in the apps, to avoid creating a dependency on a specific vendor in this library. Sentry is OpenTelemetry under-the-hood, so the instrumentation here will "just work" with Sentry, as seen with the
test-sentry.jsexample, the result of which can be seen here.Adding spans for tracing is a little more verbose than I would like - for the first time I understand why the JS Decorators approach exists, which would make the syntax a lot neater.
We need to decide what we want to add traces to, and how we want to instrument it. We could instrument dependencies like better-sqlite3 and hypercore separately, like is done with
opentelemetry-plugin-better-sqlite3, however in experimenting, wrapping methods with module hooks is tricky to get working, and possibly impossible with bundled code, and the better-sqlite3 plugin adds way too much information, including all SQL calls, which is too much noise for our needs. I think we will be better wrapping what we want to measure within this module, including spans around hypercore calls and indexing etc.We need to think through semantic names and attributes for all these spans, following as much as possible Sentry's list of operations and the otel semantic conventions, which will help when filtering and viewing this info in a tool like Sentry.
For testing this approach locally, to see how spans are exported, you can run a local instance of Jaeger with:
Then run the file
test-otel.jswhich will export to Jaeger without any additional config. You can then open the Jaeger UI and view the spans that are exported.The next steps I think are:
@comapeo/core-instrumentationas a separate moduleWe may need some special code for spans for p2p connections. By default OpenTelemetry uses AsyncStorage to correctly pass context between parent and child spans within async functions, but if you are not in an async context, then child spans can get associated with the wrong parent. To solve that we probably need to manually manage inactive spans for p2p connections, and attach child spans for things like sync to the correct parent.