Skip to content

Instantly share code, notes, and snippets.

@allyjweir
Last active May 14, 2020 17:48
Show Gist options
  • Save allyjweir/ffc2cb36ed814d2be6ca78d0db54a1a2 to your computer and use it in GitHub Desktop.
Save allyjweir/ffc2cb36ed814d2be6ca78d0db54a1a2 to your computer and use it in GitHub Desktop.
import { GraphQLRequestContext } from 'apollo-server-core/dist/requestPipelineAPI';
import { Request } from 'apollo-server-env';
import beeline from 'honeycomb-beeline';
import {
DocumentNode,
GraphQLResolveInfo,
ResponsePath,
ExecutionArgs,
GraphQLOutputType,
GraphQLCompositeType,
} from 'graphql';
import { GraphQLExtension, EndHandler } from 'graphql-extensions';
import uuid from 'uuid';
import { responsePathAsString, parentResponsePathAsString } from './utils';
export default class HoneycombTracingExtension<TContext = any> implements GraphQLExtension<TContext> {
public spans: Map<string, any>;
public queryString;
public documentAST
public operationName;
public constructor() {
this.spans = new Map<string, any>();
}
public requestDidStart(o: {
request: Request;
queryString?: string;
parsedQuery?: DocumentNode;
variables?: Record<string, any>;
persistedQueryHit?: boolean;
persistedQueryRegister?: boolean;
context: TContext;
extensions?: Record<string, any>;
requestContext: GraphQLRequestContext<TContext>;
}): EndHandler {
// Generally, we'll get queryString here and not parsedQuery; we only get
// parsedQuery if you're using an OperationStore. In normal cases we'll get
// our documentAST in the execution callback after it is parsed.
this.queryString = o.queryString;
this.documentAST = o.parsedQuery;
if (beeline.traceActive()) {
const rootSpan = beeline.startSpan({ name: 'graphql_query' });
this.spans.set('', rootSpan);
} else {
beeline.startTrace();
}
return () => {
const rootSpanToFinish = this.spans.get('');
rootSpanToFinish['graphql.query_string'] = this.queryString;
beeline.finishSpan(rootSpanToFinish);
};
}
public executionDidStart(o: { executionArgs: ExecutionArgs }) {
// If the operationName is explicitly provided, save it. If there's just one
// named operation, the client doesn't have to provide it, but we still want
// to know the operation name so that the server can identify the query by
// it without having to parse a signature.
//
// Fortunately, in the non-error case, we can just pull this out of
// the first call to willResolveField's `info` argument. In an
// error case (eg, the operationName isn't found, or there are more
// than one operation and no specified operationName) it's OK to continue
// to file this trace under the empty operationName.
if (o.executionArgs.operationName) {
this.operationName = o.executionArgs.operationName;
}
this.documentAST = o.executionArgs.document;
}
public willResolveField(
_source: any,
_args: { [argName: string]: any },
_context: TContext,
info: GraphQLResolveInfo,
): ((error: Error | null, result: any) => void) | void {
if (this.operationName === undefined) {
this.operationName = (info.operation.name && info.operation.name.value) || '';
}
this.newSpan(info.path, info.returnType, info.parentType);
return () => {
const spanToFinish = this.spans.get(responsePathAsString(info.path));
spanToFinish['graphql.operation_name'] = this.operationName;
if (spanToFinish) {
beeline.finishSpan(spanToFinish);
}
};
}
private newSpan(path: ResponsePath, returnType: GraphQLOutputType, parentType: GraphQLCompositeType) {
const fieldResponsePath = responsePathAsString(path);
const context = {
name: 'graphql_field_resolver',
'graphql.type': returnType.toString(),
'graphql.parent_type': parentType.toString(),
'graphql.field_path': fieldResponsePath,
};
const id = path && path.key;
if (path && path.prev && typeof path.prev.key === 'number') {
context['graphql.field_name'] = `${path.prev.key}.${id}`;
} else {
context['graphql.field_name'] = id;
}
let parentSpanId;
if (path && path.prev) {
const parentSpan = this.spans.get(parentResponsePathAsString(path));
if (parentSpan) {
parentSpanId = parentSpan['trace.span_id'];
}
}
const span = beeline.startSpan(context, uuid(), parentSpanId);
this.spans.set(fieldResponsePath, span);
return span;
}
}
@hboylan
Copy link

hboylan commented May 14, 2020

@allyjweir This is great! I'm curious to know what the 2 utils functions would look like.

@allyjweir
Copy link
Author

allyjweir commented May 14, 2020

I don't make use of this code anymore, it was a prototype at a previous job so not got tonnes of help to give I'm afraid.

IIRC the responsePathAsString and parentResponsePathAsString took a ResponsePath object and did something along the lines of concatenating using . as a separator. For example,

['foo', 'bar', 'baz'] became foo.bar.baz.

Where this fell down was that responsePathAsString wasn't very good with arrays as (as far as I remember) the ResponsePath object didn't have any concept of an array element's index within the wider collection.

@hboylan
Copy link

hboylan commented May 14, 2020

I've run into the same exact issue with arrays. My solution was to drop indices from the parent path, but the parent-child hierarchy still doesn't resolve correctly. This results in several missing parent spans in some traces.

In case others stumble across this, the following might be a helpful starting point which could be further optimized:

const responsePathAsString = (path: ResponsePath) => {
  return responsePathAsArray(path).join('.')
}

const parentResponsePathAsString = (path: ResponsePath) => {
  const arr = responsePathAsArray(path)
  arr.pop()

  // remove trailiing indices
  let trailingIndex = -1
  arr.forEach((value, index) => {
    if (typeof value === 'number') {
      trailingIndex = index
    }
  })

  if (trailingIndex > -1) {
    arr.splice(trailingIndex, arr.length - trailingIndex)
  }

  // combine array of resolved fields into one
  return arr.join('.')
}

@allyjweir
Copy link
Author

allyjweir commented May 14, 2020

Yeah at the time I speculated that Apollo were doing some munging to the data to append array indices to the correct spans to make their tracing solution work.

I did consider this with Honeycomb but you need to be able to view multiple events at once and while beelines-nodejs has a pre-send hook, that is on a per event basis and trying to do weird state tracking across events it was going to be a mega-hacky solution.

EDIT: There's lots of discussion around this topic in the Honeycomb Pollinators Slack. Worth joining if you aren't already a member.

@hboylan
Copy link

hboylan commented May 14, 2020

That makes sense. For now, I'll just flatten arrays to avoid a hacky solution. Hopefully beeline will support this use case in the future.

Appreciate you sharing this!

@allyjweir
Copy link
Author

allyjweir commented May 14, 2020 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment