-
Notifications
You must be signed in to change notification settings - Fork 39
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
feat: Add get_chain_root_span
utility for langchain instrumentation
#1054
Conversation
Can you please add a test similar to this one, and make sure it's working with concurrency? Thanks! |
aefa72c
to
298318a
Compare
python/instrumentation/openinference-instrumentation-langchain/tests/test_instrumentor.py
Outdated
Show resolved
Hide resolved
...eninference-instrumentation-langchain/src/openinference/instrumentation/langchain/_tracer.py
Outdated
Show resolved
Hide resolved
...eninference-instrumentation-langchain/src/openinference/instrumentation/langchain/_tracer.py
Outdated
Show resolved
Hide resolved
...eninference-instrumentation-langchain/src/openinference/instrumentation/langchain/_tracer.py
Outdated
Show resolved
Hide resolved
), "Did not capture all root spans during execution" | ||
|
||
assert ( | ||
len(set(id(span) for span in root_spans_during_execution)) == 2 * n |
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.
- Shouldn't the number of root spans be
n
, i.e. one for each run, since there aren
runs?- Otherwise this test is not really testing the tree climbing procedure.
- Since we get
ReadableSpan
fromin_memory_span_exporter
, it's easy to determine which is root, i.e. whenspan.parent is None
.
- Should we also check to see if root
span_id
is actually correct, in addition to counting them?- Otherwise it could just be any span (if we refactor the code), and we wouldn't be sure.
- Should we run the same test for the threaded version?
- Right now the threaded test above is different.
- Ideally we should ensure our implementation works for both types of concurrency.
len(set(id(span) for span in root_spans_during_execution)) == 2 * n | |
len(set(id(span) for span in root_spans_during_execution)) == n |
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.
The RunnableLambdas create their own chain, so they are their own root
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.
The RunnableLambdas create their own chain, so they are their own root
If that's the case then this is not really testing the tree climbing procedure. Is that correct?
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 not, though if like you suggested we yield the the ancestor chains we can test how many there are. I didn't end up doing that since it feels like a confusing interface?
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 not related to yielding the ancestor chain. All i'm pointing out is that we are not testing what we have implemented.
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 know that, I'm suggesting yielding the ancestor chain can be an easy way to do that
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 know that
So are you suggesting that the logic you have implemented so far doesn't need to be tested?
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 not saying it has to be tested, but I’ve noticed the logic isn’t being tested. Since we seem to agree on that observation, I’d like to understand why leaving it untested is considered the better option.
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 am specifically offering an alternative strategy to testing whether or not it's the right ancestor directly: if we change this implementation to yield ancestors we can do (I think) a good enough test by counting how many ancestors are yielded each time instead of directly checking the ancestor directly.
Regarding needing to test it: I can go either way on it, I'm not positive this test is needed though I agree it would be nice. I'm using tests as a guide here to scaffold the implementation, not necessarily be exhaustive.
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 not positive this test is needed though I agree it would be nice. I'm using tests as a guide here to scaffold the implementation, not necessarily be exhaustive.
While I understand the test may not seem essential right now, it’s important to consider that this repo is updated only occasionally. Given the infrequent changes and the high likelihood that any future updates may be handled by someone new, having tests that are both thorough and precise can be incredibly beneficial. These tests can serve as reliable documentation and help prevent potential issues when the codebase is revisited later.
I am specifically offering an alternative strategy to testing
As an aside, I previously mentioned that the self.run_map from the base instrumentor already stores the family tree of UUIDs. You can leverage this to simplify both the implementation and the testing, as it’s the ultimate source of truth for the family tree.
...eninference-instrumentation-langchain/src/openinference/instrumentation/langchain/_tracer.py
Outdated
Show resolved
Hide resolved
python/instrumentation/openinference-instrumentation-langchain/tests/test_instrumentor.py
Outdated
Show resolved
Hide resolved
...ninference-instrumentation-langchain/src/openinference/instrumentation/langchain/__init__.py
Outdated
Show resolved
Hide resolved
@@ -64,6 +64,20 @@ def _uninstrument(self, **kwargs: Any) -> None: | |||
def get_span(self, run_id: UUID) -> Optional[Span]: | |||
return self._tracer.get_span(run_id) if self._tracer else None | |||
|
|||
def get_root_chain_span(self, run_id: UUID) -> Optional[Span]: |
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 by making the following signature change, we could make this more general and at the same time simplify our internal logic, given the following compositional equivalence. This simplifies our backend logic because we only need a Dict[UUID, Optional[UUID]]
to track whose parent is whom, and we can just call on the preexisting self.run_map
.
get_root_chain_span(run_id) is get_span(get_root_chain_run_id(run_id))
We just have to also update get_span
to take None
as argument, which is a trivial change.
def get_root_chain_span(self, run_id: UUID) -> Optional[Span]: | |
def get_root_chain_run_id(self, run_id: UUID) -> Optional[UUID]: |
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.
FYI, self.run_map
seems to be all you need.
if span_id in tracer._root_span_ids: | ||
return span | ||
|
||
span = tracer._parent_span_by_span_id.get(span_id) # get parent span |
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.
Strictly speaking, we should not be using private methods in this scope.
...ninference-instrumentation-langchain/src/openinference/instrumentation/langchain/__init__.py
Outdated
Show resolved
Hide resolved
@@ -64,6 +64,19 @@ def _uninstrument(self, **kwargs: Any) -> None: | |||
def get_span(self, run_id: UUID) -> Optional[Span]: | |||
return self._tracer.get_span(run_id) if self._tracer else None | |||
|
|||
def get_root_chain_spans(self, run_id: UUID) -> Optional[List[Span]]: |
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.
Unless you disagree, “root” typically refers to just a single node, so naming the function that way would be misleading if it intends to return a list. Based on my reading, this function returns all ancestors that are chains, and skips those that are not, so if the final root of the tree is not a chain
, it is actually not returned.
root_chain_spans.append(span) | ||
|
||
span = tracer._parent_span_by_span_id.get(span_id) # get parent span | ||
return root_chain_spans if root_chain_spans else None |
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.
nit. might as well just return an empty list, since it makes no practical difference but simplifies the types.
return root_chain_spans if root_chain_spans else None | |
return root_chain_spans |
span_id = span.get_span_context().span_id | ||
tracer = self._tracer | ||
assert tracer | ||
if span_id in tracer._root_span_ids: |
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.
As mentioned earlier, in this function you could use the existing tracer.run_map
from the base Tracer
to simplify your implementation, rather than accessing private attributes in this scope. Since tracer.run_map
already provides all the necessary functionality, there’s no need to re-implement everything from scratch: the parts highlighted by arrows in the screenshot below would give you the parent_run_id
and run_type
that you're using for your tree traversal algorithm.
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.
Let's give it one stab to have one tree data structure to rule them all. It probably is worth it if we can do that.
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.
Approved to unblock. Concerns are noted in comments
@@ -64,6 +64,19 @@ def _uninstrument(self, **kwargs: Any) -> None: | |||
def get_span(self, run_id: UUID) -> Optional[Span]: | |||
return self._tracer.get_span(run_id) if self._tracer else None | |||
|
|||
def get_root_chain_spans(self, run_id: UUID) -> Optional[List[Span]]: |
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.
Maybe tying to "chain" might not be the best first approach. What about just get_ancestors
which traverses up the run tree is best for now? then we can at least say the last one is "likely" to be a root?
I would also add a docstring here to explain it.
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.
rationale for not tying to "chain" is because we might have things just be "agent" for disambiguation
span_id = span.get_span_context().span_id | ||
tracer = self._tracer | ||
assert tracer | ||
if span_id in tracer._root_span_ids: |
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.
Let's give it one stab to have one tree data structure to rule them all. It probably is worth it if we can do that.
@@ -64,6 +64,26 @@ def _uninstrument(self, **kwargs: Any) -> None: | |||
def get_span(self, run_id: UUID) -> Optional[Span]: | |||
return self._tracer.get_span(run_id) if self._tracer else None | |||
|
|||
def get_ancestors(self, run_id: UUID) -> Optional[List[Span]]: |
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.
def get_ancestors(self, run_id: UUID) -> Optional[List[Span]]: | |
def get_ancestor_spans(self, run_id: UUID) -> Optional[List[Span]]: |
just to be explicit about the return type
|
||
run = tracer.run_map.get(str(run_id)) | ||
run_id = run.parent_run_id | ||
return ancestors if ancestors else None |
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 for the simplicity of the typing (and maybe just my FP brain) - I think an empty array might be preferable to represent "none"?
partially resolves #4158
resolves #1052
Adds a utility to get the most recent chain span that can be found traversing up the trace tree. This will help grab the most appropriate span for sending user feedback.