diff --git a/.idea/runConfigurations/System_Test.xml b/.idea/runConfigurations/System_Test.xml index fde3707ba..9dad0feea 100644 --- a/.idea/runConfigurations/System_Test.xml +++ b/.idea/runConfigurations/System_Test.xml @@ -10,8 +10,8 @@ bdd --require ts-node/register/type-check --no-cache --timeout 60000 - TEST_FILE - $PROJECT_DIR$/dev/system-test/firestore.ts + PATTERN + $PROJECT_DIR$/dev/system-test/*.js $PROJECT_DIR$/dev/system-test/*.ts \ No newline at end of file diff --git a/dev/src/bulk-writer.ts b/dev/src/bulk-writer.ts index 1acca5aa5..4b2f2621f 100644 --- a/dev/src/bulk-writer.ts +++ b/dev/src/bulk-writer.ts @@ -49,6 +49,10 @@ import {StatusCode} from './status-code'; // eslint-disable-next-line no-undef import GrpcStatus = FirebaseFirestore.GrpcStatus; import api = google.firestore.v1; +import { + ATTRIBUTE_KEY_DOC_COUNT, + SPAN_NAME_BULK_WRITER_COMMIT, +} from './telemetry/trace-util'; /*! * The maximum number of writes that can be in a single batch. @@ -243,55 +247,63 @@ class BulkCommitBatch extends WriteBatch { } async bulkCommit(options: {requestTag?: string} = {}): Promise { - const tag = options?.requestTag ?? requestTag(); - - // Capture the error stack to preserve stack tracing across async calls. - const stack = Error().stack!; - - let response: api.IBatchWriteResponse; - try { - logger( - 'BulkCommitBatch.bulkCommit', - tag, - `Sending next batch with ${this._opCount} writes` - ); - const retryCodes = getRetryCodes('batchWrite'); - response = await this._commit< - api.BatchWriteRequest, - api.BatchWriteResponse - >({retryCodes, methodName: 'batchWrite', requestTag: tag}); - } catch (err) { - // Map the failure to each individual write's result. - const ops = Array.from({length: this.pendingOps.length}); - response = { - writeResults: ops.map(() => { - return {}; - }), - status: ops.map(() => err), - }; - } - - for (let i = 0; i < (response.writeResults || []).length; ++i) { - // Since delete operations currently do not have write times, use a - // sentinel Timestamp value. - // TODO(b/158502664): Use actual delete timestamp. - const DELETE_TIMESTAMP_SENTINEL = Timestamp.fromMillis(0); - - const status = (response.status || [])[i]; - if (status.code === StatusCode.OK) { - const updateTime = Timestamp.fromProto( - response.writeResults![i].updateTime || DELETE_TIMESTAMP_SENTINEL - ); - this.pendingOps[i].onSuccess(new WriteResult(updateTime)); - } else { - const error = - new (require('google-gax/build/src/fallback').GoogleError)( - status.message || undefined + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_BULK_WRITER_COMMIT, + async () => { + const tag = options?.requestTag ?? requestTag(); + + // Capture the error stack to preserve stack tracing across async calls. + const stack = Error().stack!; + + let response: api.IBatchWriteResponse; + try { + logger( + 'BulkCommitBatch.bulkCommit', + tag, + `Sending next batch with ${this._opCount} writes` ); - error.code = status.code as number; - this.pendingOps[i].onError(wrapError(error, stack)); + const retryCodes = getRetryCodes('batchWrite'); + response = await this._commit< + api.BatchWriteRequest, + api.BatchWriteResponse + >({retryCodes, methodName: 'batchWrite', requestTag: tag}); + } catch (err) { + // Map the failure to each individual write's result. + const ops = Array.from({length: this.pendingOps.length}); + response = { + writeResults: ops.map(() => { + return {}; + }), + status: ops.map(() => err), + }; + } + + for (let i = 0; i < (response.writeResults || []).length; ++i) { + // Since delete operations currently do not have write times, use a + // sentinel Timestamp value. + // TODO(b/158502664): Use actual delete timestamp. + const DELETE_TIMESTAMP_SENTINEL = Timestamp.fromMillis(0); + + const status = (response.status || [])[i]; + if (status.code === StatusCode.OK) { + const updateTime = Timestamp.fromProto( + response.writeResults![i].updateTime || DELETE_TIMESTAMP_SENTINEL + ); + this.pendingOps[i].onSuccess(new WriteResult(updateTime)); + } else { + const error = + new (require('google-gax/build/src/fallback').GoogleError)( + status.message || undefined + ); + error.code = status.code as number; + this.pendingOps[i].onError(wrapError(error, stack)); + } + } + }, + { + [ATTRIBUTE_KEY_DOC_COUNT]: this._opCount, } - } + ); } /** diff --git a/dev/src/collection-group.ts b/dev/src/collection-group.ts index 363cd6de6..92ca05879 100644 --- a/dev/src/collection-group.ts +++ b/dev/src/collection-group.ts @@ -29,6 +29,7 @@ import {validateInteger} from './validate'; import api = protos.google.firestore.v1; import {defaultConverter} from './types'; import {compareArrays} from './order'; +import {SPAN_NAME_PARTITION_QUERY} from './telemetry/trace-util'; /** * A `CollectionGroup` refers to all documents that are contained in a @@ -81,48 +82,53 @@ export class CollectionGroup< async *getPartitions( desiredPartitionCount: number ): AsyncIterable> { - validateInteger('desiredPartitionCount', desiredPartitionCount, { - minValue: 1, - }); - - const tag = requestTag(); - await this.firestore.initializeIfNeeded(tag); - const partitions: Array[] = []; - if (desiredPartitionCount > 1) { - // Partition queries require explicit ordering by __name__. - const queryWithDefaultOrder = this.orderBy(FieldPath.documentId()); - const request: api.IPartitionQueryRequest = - queryWithDefaultOrder.toProto(); - - // Since we are always returning an extra partition (with an empty endBefore - // cursor), we reduce the desired partition count by one. - request.partitionCount = desiredPartitionCount - 1; - - const stream = await this.firestore.requestStream( - 'partitionQueryStream', - /* bidirectional= */ false, - request, - tag - ); - stream.resume(); - - for await (const currentCursor of stream) { - partitions.push(currentCursor.values ?? []); + await this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_PARTITION_QUERY, + async () => { + validateInteger('desiredPartitionCount', desiredPartitionCount, { + minValue: 1, + }); + + const tag = requestTag(); + await this.firestore.initializeIfNeeded(tag); + + if (desiredPartitionCount > 1) { + // Partition queries require explicit ordering by __name__. + const queryWithDefaultOrder = this.orderBy(FieldPath.documentId()); + const request: api.IPartitionQueryRequest = + queryWithDefaultOrder.toProto(); + + // Since we are always returning an extra partition (with an empty endBefore + // cursor), we reduce the desired partition count by one. + request.partitionCount = desiredPartitionCount - 1; + + const stream = await this.firestore.requestStream( + 'partitionQueryStream', + /* bidirectional= */ false, + request, + tag + ); + stream.resume(); + + for await (const currentCursor of stream) { + partitions.push(currentCursor.values ?? []); + } + } + + logger( + 'Firestore.getPartitions', + tag, + 'Received %d partitions', + partitions.length + ); + + // Sort the partitions as they may not be ordered if responses are paged. + partitions.sort((l, r) => compareArrays(l, r)); } - } - - logger( - 'Firestore.getPartitions', - tag, - 'Received %d partitions', - partitions.length ); - // Sort the partitions as they may not be ordered if responses are paged. - partitions.sort((l, r) => compareArrays(l, r)); - for (let i = 0; i < partitions.length; ++i) { yield new QueryPartition( this._firestore, diff --git a/dev/src/index.ts b/dev/src/index.ts index 8d470c29e..a55aa96d5 100644 --- a/dev/src/index.ts +++ b/dev/src/index.ts @@ -85,6 +85,15 @@ import { RECURSIVE_DELETE_MIN_PENDING_OPS, RecursiveDelete, } from './recursive-delete'; +import { + ATTRIBUTE_KEY_DOC_COUNT, + ATTRIBUTE_KEY_IS_TRANSACTIONAL, + ATTRIBUTE_KEY_NUM_RESPONSES, + SPAN_NAME_BATCH_GET_DOCUMENTS, + TraceUtil, +} from './telemetry/trace-util'; +import {DisabledTraceUtil} from './telemetry/disabled-trace-util'; +import {EnabledTraceUtil} from './telemetry/enabled-trace-util'; export {CollectionReference} from './reference/collection-reference'; export {DocumentReference} from './reference/document-reference'; @@ -455,6 +464,13 @@ export class Firestore implements firestore.Firestore { */ _serializer: Serializer | null = null; + /** + * The OpenTelemetry tracing utility object. + * @private + * @internal + */ + _traceUtil: TraceUtil; + /** * The project ID for this client. * @@ -573,6 +589,8 @@ export class Firestore implements firestore.Firestore { this.validateAndApplySettings({...settings, ...libraryHeader}); + this._traceUtil = this.newTraceUtilInstance(this._settings); + const retryConfig = serviceConfig.retry_params.default; this._backoffSettings = { initialDelayMs: retryConfig.initial_retry_delay_millis, @@ -770,6 +788,34 @@ export class Firestore implements firestore.Firestore { return temp; }; this._serializer = new Serializer(this); + this._traceUtil = this.newTraceUtilInstance(this._settings); + } + + private newTraceUtilInstance(settings: firestore.Settings): TraceUtil { + // Take the tracing option from the settings. + let createEnabledInstance = settings.openTelemetryOptions?.enableTracing; + + // The environment variable can override options to enable/disable telemetry collection. + if ('FIRESTORE_ENABLE_TRACING' in process.env) { + const enableTracingEnvVar = + process.env.FIRESTORE_ENABLE_TRACING!.toLowerCase(); + if (enableTracingEnvVar === 'on' || enableTracingEnvVar === 'true') { + createEnabledInstance = true; + } + if (enableTracingEnvVar === 'off' || enableTracingEnvVar === 'false') { + createEnabledInstance = false; + } + } + + if (createEnabledInstance) { + // Re-use the existing EnabledTraceUtil if one has been created. + if (this._traceUtil && this._traceUtil instanceof EnabledTraceUtil) { + return this._traceUtil; + } + return new EnabledTraceUtil(settings); + } else { + return new DisabledTraceUtil(); + } } /** @@ -1276,28 +1322,39 @@ export class Firestore implements firestore.Firestore { | firestore.ReadOptions > ): Promise>> { - validateMinNumberOfArguments( - 'Firestore.getAll', - documentRefsOrReadOptions, - 1 - ); + return this._traceUtil.startActiveSpan( + SPAN_NAME_BATCH_GET_DOCUMENTS, + () => { + validateMinNumberOfArguments( + 'Firestore.getAll', + documentRefsOrReadOptions, + 1 + ); - const {documents, fieldMask} = parseGetAllArguments( - documentRefsOrReadOptions - ); - const tag = requestTag(); + const {documents, fieldMask} = parseGetAllArguments( + documentRefsOrReadOptions + ); - // Capture the error stack to preserve stack tracing across async calls. - const stack = Error().stack!; + this._traceUtil.currentSpan().setAttributes({ + [ATTRIBUTE_KEY_IS_TRANSACTIONAL]: false, + [ATTRIBUTE_KEY_DOC_COUNT]: documents.length, + }); - return this.initializeIfNeeded(tag) - .then(() => { - const reader = new DocumentReader(this, documents, fieldMask); - return reader.get(tag); - }) - .catch(err => { - throw wrapError(err, stack); - }); + const tag = requestTag(); + + // Capture the error stack to preserve stack tracing across async calls. + const stack = Error().stack!; + + return this.initializeIfNeeded(tag) + .then(() => { + const reader = new DocumentReader(this, documents, fieldMask); + return reader.get(tag); + }) + .catch(err => { + throw wrapError(err, stack); + }); + } + ); } /** @@ -1787,6 +1844,8 @@ export class Firestore implements firestore.Firestore { const callOptions = this.createCallOptions(methodName); const bidirectional = methodName === 'listen'; + let numResponses = 0; + const NUM_RESPONSES_PER_TRACE_EVENT = 100; return this._retry(methodName, requestTag, () => { const result = new Deferred(); @@ -1798,6 +1857,11 @@ export class Firestore implements firestore.Firestore { 'Sending request: %j', request ); + + this._traceUtil + .currentSpan() + .addEvent(`Firestore.${methodName}: Start`); + try { const stream = bidirectional ? gapicClient[methodName](callOptions) @@ -1811,6 +1875,18 @@ export class Firestore implements firestore.Firestore { 'Received response: %j', chunk ); + numResponses++; + if (numResponses === 1) { + this._traceUtil + .currentSpan() + .addEvent(`Firestore.${methodName}: First response received`); + } else if (numResponses % NUM_RESPONSES_PER_TRACE_EVENT === 0) { + this._traceUtil + .currentSpan() + .addEvent( + `Firestore.${methodName}: Received ${numResponses} responses` + ); + } callback(); }, }); @@ -1823,7 +1899,14 @@ export class Firestore implements firestore.Firestore { requestTag, bidirectional ? request : undefined ); - resultStream.on('end', () => stream.end()); + resultStream.on('end', () => { + stream.end(); + this._traceUtil + .currentSpan() + .addEvent(`Firestore.${methodName}: Completed`, { + [ATTRIBUTE_KEY_NUM_RESPONSES]: numResponses, + }); + }); result.resolve(resultStream); // While we return the stream to the callee early, we don't want to diff --git a/dev/src/reference/aggregate-query.ts b/dev/src/reference/aggregate-query.ts index 6171c34b4..c86df1812 100644 --- a/dev/src/reference/aggregate-query.ts +++ b/dev/src/reference/aggregate-query.ts @@ -30,6 +30,10 @@ import {AggregateQuerySnapshot} from './aggregate-query-snapshot'; import {Query} from './query'; import {Readable, Transform} from 'stream'; import {QueryResponse, QuerySnapshotResponse} from './types'; +import { + SPAN_NAME_AGGREGATION_QUERY_GET, + SPAN_NAME_RUN_AGGREGATION_QUERY, +} from '../telemetry/trace-util'; /** * A query that calculates aggregations over an underlying query. @@ -81,8 +85,13 @@ export class AggregateQuery< async get(): Promise< AggregateQuerySnapshot > { - const {result} = await this._get(); - return result; + return this._query._firestore._traceUtil.startActiveSpan( + SPAN_NAME_AGGREGATION_QUERY_GET, + async () => { + const {result} = await this._get(); + return result; + } + ); } /** @@ -245,6 +254,13 @@ export class AggregateQuery< 'AggregateQuery failed with stream error:', err ); + + this._query._firestore._traceUtil + .currentSpan() + .addEvent(`${SPAN_NAME_RUN_AGGREGATION_QUERY}: Error.`, { + 'error.message': err.message, + }); + stream.destroy(err); }); backendStream.resume(); diff --git a/dev/src/reference/collection-reference.ts b/dev/src/reference/collection-reference.ts index f3fb919c9..8b065d64b 100644 --- a/dev/src/reference/collection-reference.ts +++ b/dev/src/reference/collection-reference.ts @@ -30,6 +30,10 @@ import {Query} from './query'; import Firestore from '../index'; import {DocumentReference} from './document-reference'; import {QueryOptions} from './query-options'; +import { + SPAN_NAME_COL_REF_ADD, + SPAN_NAME_COL_REF_LIST_DOCUMENTS, +} from '../telemetry/trace-util'; /** * A CollectionReference object can be used for adding documents, getting @@ -164,40 +168,46 @@ export class CollectionReference< listDocuments(): Promise< Array> > { - const tag = requestTag(); - return this.firestore.initializeIfNeeded(tag).then(() => { - const parentPath = this._queryOptions.parentPath.toQualifiedResourcePath( - this.firestore.projectId, - this.firestore.databaseId - ); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_COL_REF_LIST_DOCUMENTS, + () => { + const tag = requestTag(); + return this.firestore.initializeIfNeeded(tag).then(() => { + const parentPath = + this._queryOptions.parentPath.toQualifiedResourcePath( + this.firestore.projectId, + this.firestore.databaseId + ); - const request: api.IListDocumentsRequest = { - parent: parentPath.formattedName, - collectionId: this.id, - showMissing: true, - // Setting `pageSize` to an arbitrarily large value lets the backend cap - // the page size (currently to 300). Note that the backend rejects - // MAX_INT32 (b/146883794). - pageSize: Math.pow(2, 16) - 1, - mask: {fieldPaths: []}, - }; + const request: api.IListDocumentsRequest = { + parent: parentPath.formattedName, + collectionId: this.id, + showMissing: true, + // Setting `pageSize` to an arbitrarily large value lets the backend cap + // the page size (currently to 300). Note that the backend rejects + // MAX_INT32 (b/146883794). + pageSize: Math.pow(2, 16) - 1, + mask: {fieldPaths: []}, + }; - return this.firestore - .request< - api.IListDocumentsRequest, - api.IDocument[] - >('listDocuments', request, tag) - .then(documents => { - // Note that the backend already orders these documents by name, - // so we do not need to manually sort them. - return documents.map(doc => { - const path = QualifiedResourcePath.fromSlashSeparatedString( - doc.name! - ); - return this.doc(path.id!); - }); + return this.firestore + .request< + api.IListDocumentsRequest, + api.IDocument[] + >('listDocuments', request, tag) + .then(documents => { + // Note that the backend already orders these documents by name, + // so we do not need to manually sort them. + return documents.map(doc => { + const path = QualifiedResourcePath.fromSlashSeparatedString( + doc.name! + ); + return this.doc(path.id!); + }); + }); }); - }); + } + ); } doc(): DocumentReference; @@ -264,16 +274,21 @@ export class CollectionReference< add( data: firestore.WithFieldValue ): Promise> { - const firestoreData = this._queryOptions.converter.toFirestore(data); - validateDocumentData( - 'data', - firestoreData, - /*allowDeletes=*/ false, - this._allowUndefined - ); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_COL_REF_ADD, + () => { + const firestoreData = this._queryOptions.converter.toFirestore(data); + validateDocumentData( + 'data', + firestoreData, + /*allowDeletes=*/ false, + this._allowUndefined + ); - const documentRef = this.doc(); - return documentRef.create(data).then(() => documentRef); + const documentRef = this.doc(); + return documentRef.create(data).then(() => documentRef); + } + ); } /** diff --git a/dev/src/reference/document-reference.ts b/dev/src/reference/document-reference.ts index 0af5e549e..52f6d8425 100644 --- a/dev/src/reference/document-reference.ts +++ b/dev/src/reference/document-reference.ts @@ -27,6 +27,14 @@ import {requestTag} from '../util'; import {validateFunction, validateMinNumberOfArguments} from '../validate'; import {DocumentWatch} from '../watch'; import {DocumentSnapshotBuilder} from '../document'; +import { + SPAN_NAME_DOC_REF_CREATE, + SPAN_NAME_DOC_REF_DELETE, + SPAN_NAME_DOC_REF_GET, + SPAN_NAME_DOC_REF_LIST_COLLECTIONS, + SPAN_NAME_DOC_REF_SET, + SPAN_NAME_DOC_REF_UPDATE, +} from '../telemetry/trace-util'; /** * A DocumentReference refers to a document location in a Firestore database @@ -198,7 +206,12 @@ export class DocumentReference< * ``` */ get(): Promise> { - return this._firestore.getAll(this).then(([result]) => result); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_DOC_REF_GET, + () => { + return this._firestore.getAll(this).then(([result]) => result); + } + ); } /** @@ -247,34 +260,39 @@ export class DocumentReference< * ``` */ listCollections(): Promise> { - const tag = requestTag(); - return this.firestore.initializeIfNeeded(tag).then(() => { - const request: api.IListCollectionIdsRequest = { - parent: this.formattedName, - // Setting `pageSize` to an arbitrarily large value lets the backend cap - // the page size (currently to 300). Note that the backend rejects - // MAX_INT32 (b/146883794). - pageSize: Math.pow(2, 16) - 1, - }; - return this._firestore - .request< - api.IListCollectionIdsRequest, - string[] - >('listCollectionIds', request, tag) - .then(collectionIds => { - const collections: Array = []; + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_DOC_REF_LIST_COLLECTIONS, + () => { + const tag = requestTag(); + return this.firestore.initializeIfNeeded(tag).then(() => { + const request: api.IListCollectionIdsRequest = { + parent: this.formattedName, + // Setting `pageSize` to an arbitrarily large value lets the backend cap + // the page size (currently to 300). Note that the backend rejects + // MAX_INT32 (b/146883794). + pageSize: Math.pow(2, 16) - 1, + }; + return this._firestore + .request< + api.IListCollectionIdsRequest, + string[] + >('listCollectionIds', request, tag) + .then(collectionIds => { + const collections: Array = []; - // We can just sort this list using the default comparator since it - // will only contain collection ids. - collectionIds.sort(); + // We can just sort this list using the default comparator since it + // will only contain collection ids. + collectionIds.sort(); - for (const collectionId of collectionIds) { - collections.push(this.collection(collectionId)); - } + for (const collectionId of collectionIds) { + collections.push(this.collection(collectionId)); + } - return collections; + return collections; + }); }); - }); + } + ); } /** @@ -299,11 +317,16 @@ export class DocumentReference< * ``` */ create(data: firestore.WithFieldValue): Promise { - const writeBatch = new WriteBatch(this._firestore); - return writeBatch - .create(this, data) - .commit() - .then(([writeResult]) => writeResult); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_DOC_REF_CREATE, + () => { + const writeBatch = new WriteBatch(this._firestore); + return writeBatch + .create(this, data) + .commit() + .then(([writeResult]) => writeResult); + } + ); } /** @@ -332,11 +355,16 @@ export class DocumentReference< * ``` */ delete(precondition?: firestore.Precondition): Promise { - const writeBatch = new WriteBatch(this._firestore); - return writeBatch - .delete(this, precondition) - .commit() - .then(([writeResult]) => writeResult); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_DOC_REF_DELETE, + () => { + const writeBatch = new WriteBatch(this._firestore); + return writeBatch + .delete(this, precondition) + .commit() + .then(([writeResult]) => writeResult); + } + ); } set( @@ -378,16 +406,21 @@ export class DocumentReference< data: firestore.PartialWithFieldValue, options?: firestore.SetOptions ): Promise { - let writeBatch = new WriteBatch(this._firestore); - if (options) { - writeBatch = writeBatch.set(this, data, options); - } else { - writeBatch = writeBatch.set( - this, - data as firestore.WithFieldValue - ); - } - return writeBatch.commit().then(([writeResult]) => writeResult); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_DOC_REF_SET, + () => { + let writeBatch = new WriteBatch(this._firestore); + if (options) { + writeBatch = writeBatch.set(this, data, options); + } else { + writeBatch = writeBatch.set( + this, + data as firestore.WithFieldValue + ); + } + return writeBatch.commit().then(([writeResult]) => writeResult); + } + ); } /** @@ -431,14 +464,19 @@ export class DocumentReference< unknown | string | firestore.FieldPath | firestore.Precondition > ): Promise { - // eslint-disable-next-line prefer-rest-params - validateMinNumberOfArguments('DocumentReference.update', arguments, 1); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_DOC_REF_UPDATE, + () => { + // eslint-disable-next-line prefer-rest-params + validateMinNumberOfArguments('DocumentReference.update', arguments, 1); - const writeBatch = new WriteBatch(this._firestore); - return writeBatch - .update(this, dataOrField, ...preconditionOrValues) - .commit() - .then(([writeResult]) => writeResult); + const writeBatch = new WriteBatch(this._firestore); + return writeBatch + .update(this, dataOrField, ...preconditionOrValues) + .commit() + .then(([writeResult]) => writeResult); + } + ); } /** diff --git a/dev/src/reference/query-util.ts b/dev/src/reference/query-util.ts index a475ce7a2..c615bc254 100644 --- a/dev/src/reference/query-util.ts +++ b/dev/src/reference/query-util.ts @@ -40,6 +40,11 @@ import {NOOP_MESSAGE} from './constants'; import * as protos from '../../protos/firestore_v1_proto_api'; import api = protos.google.firestore.v1; +import { + ATTRIBUTE_KEY_IS_RETRY_WITH_CURSOR, + ATTRIBUTE_KEY_IS_TRANSACTIONAL, + SPAN_NAME_RUN_QUERY, +} from '../telemetry/trace-util'; export class QueryUtil< AppModelType, @@ -254,10 +259,19 @@ export class QueryUtil< // catch below. let request = query.toProto(transactionOrReadTime, explainOptions); + let isRetryRequestWithCursor = false; let streamActive: Deferred; do { streamActive = new Deferred(); const methodName = 'runQuery'; + + this._firestore._traceUtil + .currentSpan() + .addEvent(SPAN_NAME_RUN_QUERY, { + [ATTRIBUTE_KEY_IS_TRANSACTIONAL]: !!request.transaction, + [ATTRIBUTE_KEY_IS_RETRY_WITH_CURSOR]: isRetryRequestWithCursor, + }); + backendStream = await this._firestore.requestStream( methodName, /* bidirectional= */ false, @@ -274,7 +288,7 @@ export class QueryUtil< if ( !isExplain && !transactionOrReadTime && - !this._isPermanentRpcError(err, 'runQuery') + !this._isPermanentRpcError(err, methodName) ) { logger( 'QueryUtil._stream', @@ -283,6 +297,12 @@ export class QueryUtil< err ); + this._firestore._traceUtil + .currentSpan() + .addEvent(`${SPAN_NAME_RUN_QUERY}: Retryable Error.`, { + 'error.message': err.message, + }); + // Enqueue a "no-op" write into the stream and wait for it to be // read by the downstream consumer. This ensures that all enqueued // results in the stream are consumed, which will give us an accurate @@ -304,6 +324,8 @@ export class QueryUtil< 'documents, so the stream is being retried.' ); + isRetryRequestWithCursor = true; + // Restart the query but use the last document we received as // the query cursor. Note that we do not use backoff here. The // call to `requestStream()` will backoff should the restart @@ -338,6 +360,13 @@ export class QueryUtil< 'Query failed with stream error:', err ); + + this._firestore._traceUtil + .currentSpan() + .addEvent(`${SPAN_NAME_RUN_QUERY}: Error.`, { + 'error.message': err.message, + }); + stream.destroy(err); streamActive.resolve(/* active= */ false); } diff --git a/dev/src/reference/query.ts b/dev/src/reference/query.ts index f7664b4d5..e2811de3b 100644 --- a/dev/src/reference/query.ts +++ b/dev/src/reference/query.ts @@ -68,6 +68,7 @@ import {VectorQuery} from './vector-query'; import {QueryWatch} from '../watch'; import {compare} from '../order'; import {defaultConverter} from '../types'; +import {SPAN_NAME_QUERY_GET} from '../telemetry/trace-util'; /** * A Query refers to a query which you can read or stream from. You can also @@ -1076,8 +1077,13 @@ export class Query< * ``` */ async get(): Promise> { - const {result} = await this._get(); - return result; + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_QUERY_GET, + async () => { + const {result} = await this._get(); + return result; + } + ); } /** diff --git a/dev/src/telemetry/disabled-trace-util.ts b/dev/src/telemetry/disabled-trace-util.ts new file mode 100644 index 000000000..ab78e4f2e --- /dev/null +++ b/dev/src/telemetry/disabled-trace-util.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {Attributes, TraceUtil} from './trace-util'; +import {Span} from './span'; + +export class DisabledTraceUtil implements TraceUtil { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + startSpan(name: string): Span { + return new Span(); + } + + startActiveSpan unknown>( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + name: string, + fn: F, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + attributes?: Attributes + ): ReturnType { + const emptySpan = new Span(); + return fn(emptySpan) as ReturnType; + } + + currentSpan(): Span { + return new Span(); + } +} diff --git a/dev/src/telemetry/enabled-trace-util.ts b/dev/src/telemetry/enabled-trace-util.ts new file mode 100644 index 000000000..9e45fe2a4 --- /dev/null +++ b/dev/src/telemetry/enabled-trace-util.ts @@ -0,0 +1,103 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Settings} from '@google-cloud/firestore'; + +import { + context, + SpanStatusCode, + trace, + Tracer, + Span as OpenTelemetrySpan, +} from '@opentelemetry/api'; + +import {Span} from './span'; +import {Attributes, TraceUtil} from './trace-util'; + +export class EnabledTraceUtil implements TraceUtil { + private tracer: Tracer; + + constructor(settings: Settings) { + let tracerProvider = settings.openTelemetryOptions?.tracerProvider; + + // If a TracerProvider has not been given to us, we try to use the global one. + if (!tracerProvider) { + const {trace} = require('@opentelemetry/api'); + tracerProvider = trace.getTracerProvider(); + } + + const libVersion = require('../../../package.json').version; + const libName = require('../../../package.json').name; + this.tracer = tracerProvider.getTracer(libName, libVersion); + } + + private endSpan(otelSpan: OpenTelemetrySpan, error: Error): void { + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + otelSpan.recordException(error); + otelSpan.end(); + } + + startActiveSpan unknown>( + name: string, + fn: F, + attributes?: Attributes + ): ReturnType { + return this.tracer.startActiveSpan( + name, + { + attributes: attributes, + }, + (otelSpan: OpenTelemetrySpan) => { + // Note that if `fn` returns a `Promise`, we want the otelSpan to end + // after the `Promise` has resolved, NOT after the `fn` has returned. + // Therefore, we should not use a `finally` clause to end the otelSpan. + try { + let result = fn(new Span(otelSpan)); + if (result instanceof Promise) { + result = result + .then(value => { + otelSpan.end(); + return value; + }) + .catch(error => { + this.endSpan(otelSpan, error); + // Returns a Promise.reject the same as the underlying function. + return Promise.reject(error); + }); + } else { + otelSpan.end(); + } + return result as ReturnType; + } catch (error) { + this.endSpan(otelSpan, error); + // Re-throw the exception to maintain normal error handling. + throw error; + } + } + ); + } + + startSpan(name: string): Span { + return new Span(this.tracer.startSpan(name, undefined, context.active())); + } + + currentSpan(): Span { + return new Span(trace.getActiveSpan()); + } +} diff --git a/dev/src/telemetry/span.ts b/dev/src/telemetry/span.ts new file mode 100644 index 000000000..a4ad6e249 --- /dev/null +++ b/dev/src/telemetry/span.ts @@ -0,0 +1,36 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Span as OpenTelemetrySpan} from '@opentelemetry/api'; +import {Attributes} from './trace-util'; + +export class Span { + constructor(private span?: OpenTelemetrySpan) {} + + end(): void { + this.span?.end(); + } + + addEvent(name: string, attributes?: Attributes): this { + this.span = this.span?.addEvent(name, attributes); + return this; + } + + setAttributes(attributes: Attributes): this { + this.span = this.span?.setAttributes(attributes); + return this; + } +} diff --git a/dev/src/telemetry/trace-util.ts b/dev/src/telemetry/trace-util.ts new file mode 100644 index 000000000..afa8c9ffe --- /dev/null +++ b/dev/src/telemetry/trace-util.ts @@ -0,0 +1,77 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Span} from './span'; + +export interface Attributes { + [attributeKey: string]: AttributeValue | undefined; +} +export declare type AttributeValue = + | string + | number + | boolean + | Array + | Array + | Array; + +/** + * Span names for instrumented operations. + */ +export const SERVICE = 'google.firestore.v1.Firestore/'; +export const SPAN_NAME_BATCH_GET_DOCUMENTS = 'BatchGetDocuments'; +export const SPAN_NAME_RUN_QUERY = 'RunQuery'; +export const SPAN_NAME_RUN_AGGREGATION_QUERY = 'RunAggregationQuery'; +export const SPAN_NAME_DOC_REF_CREATE = 'DocumentReference.Create'; +export const SPAN_NAME_DOC_REF_SET = 'DocumentReference.Set'; +export const SPAN_NAME_DOC_REF_UPDATE = 'DocumentReference.Update'; +export const SPAN_NAME_DOC_REF_DELETE = 'DocumentReference.Delete'; +export const SPAN_NAME_DOC_REF_GET = 'DocumentReference.Get'; +export const SPAN_NAME_DOC_REF_LIST_COLLECTIONS = + 'DocumentReference.ListCollections'; +export const SPAN_NAME_COL_REF_ADD = 'CollectionReference.Add'; +export const SPAN_NAME_COL_REF_LIST_DOCUMENTS = + 'CollectionReference.ListDocuments'; +export const SPAN_NAME_QUERY_GET = 'Query.Get'; +export const SPAN_NAME_AGGREGATION_QUERY_GET = 'AggregationQuery.Get'; +export const SPAN_NAME_TRANSACTION_RUN = 'Transaction.Run'; +export const SPAN_NAME_TRANSACTION_GET_QUERY = 'Transaction.Get.Query'; +export const SPAN_NAME_TRANSACTION_GET_AGGREGATION_QUERY = + 'Transaction.Get.AggregationQuery'; +export const SPAN_NAME_TRANSACTION_GET_DOCUMENT = 'Transaction.Get.Document'; +export const SPAN_NAME_TRANSACTION_GET_DOCUMENTS = 'Transaction.Get.Documents'; +export const SPAN_NAME_TRANSACTION_ROLLBACK = 'Transaction.Rollback'; +export const SPAN_NAME_TRANSACTION_COMMIT = 'Transaction.Commit'; +export const SPAN_NAME_BATCH_COMMIT = 'Batch.Commit'; +export const SPAN_NAME_PARTITION_QUERY = 'PartitionQuery'; +export const SPAN_NAME_BULK_WRITER_COMMIT = 'BulkWriter.Commit'; +export const ATTRIBUTE_KEY_DOC_COUNT = 'doc_count'; +export const ATTRIBUTE_KEY_IS_TRANSACTIONAL = 'transactional'; +export const ATTRIBUTE_KEY_NUM_RESPONSES = 'response_count'; +export const ATTRIBUTE_KEY_IS_RETRY_WITH_CURSOR = 'retry_query_with_cursor'; +export const ATTRIBUTE_KEY_TRANSACTION_TYPE = 'transaction_type'; +export const ATTRIBUTE_KEY_ATTEMPTS_ALLOWED = 'attempts_allowed'; +export const ATTRIBUTE_KEY_ATTEMPTS_REMAINING = 'attempts_remaining'; + +export interface TraceUtil { + startActiveSpan unknown>( + name: string, + fn: F, + attributes?: Attributes + ): ReturnType; + startSpan(name: string): Span; + + currentSpan(): Span; +} diff --git a/dev/src/transaction.ts b/dev/src/transaction.ts index f29fccbe5..ed3a8d477 100644 --- a/dev/src/transaction.ts +++ b/dev/src/transaction.ts @@ -42,6 +42,20 @@ import { } from './validate'; import {DocumentReader} from './document-reader'; import api = proto.google.firestore.v1; +import { + ATTRIBUTE_KEY_ATTEMPTS_ALLOWED, + ATTRIBUTE_KEY_ATTEMPTS_REMAINING, + ATTRIBUTE_KEY_DOC_COUNT, + ATTRIBUTE_KEY_IS_TRANSACTIONAL, + ATTRIBUTE_KEY_TRANSACTION_TYPE, + SPAN_NAME_TRANSACTION_COMMIT, + SPAN_NAME_TRANSACTION_GET_AGGREGATION_QUERY, + SPAN_NAME_TRANSACTION_GET_DOCUMENT, + SPAN_NAME_TRANSACTION_GET_DOCUMENTS, + SPAN_NAME_TRANSACTION_GET_QUERY, + SPAN_NAME_TRANSACTION_ROLLBACK, + SPAN_NAME_TRANSACTION_RUN, +} from './telemetry/trace-util'; /*! * Error message for transactional reads that were executed after performing @@ -198,11 +212,23 @@ export class Transaction implements firestore.Transaction { } if (refOrQuery instanceof DocumentReference) { - return this.withLazyStartedTransaction(refOrQuery, this.getSingleFn); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_TRANSACTION_GET_DOCUMENT, + () => { + return this.withLazyStartedTransaction(refOrQuery, this.getSingleFn); + } + ); } if (refOrQuery instanceof Query || refOrQuery instanceof AggregateQuery) { - return this.withLazyStartedTransaction(refOrQuery, this.getQueryFn); + return this._firestore._traceUtil.startActiveSpan( + refOrQuery instanceof Query + ? SPAN_NAME_TRANSACTION_GET_QUERY + : SPAN_NAME_TRANSACTION_GET_AGGREGATION_QUERY, + () => { + return this.withLazyStartedTransaction(refOrQuery, this.getQueryFn); + } + ); } throw new Error( @@ -460,27 +486,36 @@ export class Transaction implements firestore.Transaction { * @internal */ async commit(): Promise { - if (!this._writeBatch) { - throw new Error(READ_ONLY_WRITE_ERROR_MSG); - } + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_TRANSACTION_COMMIT, + async () => { + if (!this._writeBatch) { + throw new Error(READ_ONLY_WRITE_ERROR_MSG); + } - // If we have not performed any reads in this particular attempt - // then the writes will be atomically committed without a transaction ID - let transactionId: Uint8Array | undefined; - if (this._transactionIdPromise) { - transactionId = await this._transactionIdPromise; - } else if (this._writeBatch.isEmpty) { - // If we have not started a transaction (no reads) and we have no writes - // then the commit is a no-op (success) - return; - } + // If we have not performed any reads in this particular attempt + // then the writes will be atomically committed without a transaction ID + let transactionId: Uint8Array | undefined; + if (this._transactionIdPromise) { + transactionId = await this._transactionIdPromise; + } else if (this._writeBatch.isEmpty) { + // If we have not started a transaction (no reads) and we have no writes + // then the commit is a no-op (success) + return; + } - await this._writeBatch._commit({ - transactionId, - requestTag: this._requestTag, - }); - this._transactionIdPromise = undefined; - this._prevTransactionId = transactionId; + await this._writeBatch._commit({ + transactionId, + requestTag: this._requestTag, + }); + this._transactionIdPromise = undefined; + this._prevTransactionId = transactionId; + }, + { + [ATTRIBUTE_KEY_IS_TRANSACTIONAL]: true, + [ATTRIBUTE_KEY_DOC_COUNT]: this._writeBatch?._opCount, + } + ); } /** @@ -492,43 +527,48 @@ export class Transaction implements firestore.Transaction { * @internal */ async rollback(): Promise { - // No need to roll back if we have not lazily started the transaction - // or if we are read only - if (!this._transactionIdPromise || !this._writeBatch) { - return; - } + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_TRANSACTION_ROLLBACK, + async () => { + // No need to roll back if we have not lazily started the transaction + // or if we are read only + if (!this._transactionIdPromise || !this._writeBatch) { + return; + } - let transactionId: Uint8Array; - try { - transactionId = await this._transactionIdPromise; - } catch { - // This means the initial read operation rejected - // and we do not have a transaction ID to roll back - this._transactionIdPromise = undefined; - return; - } + let transactionId: Uint8Array; + try { + transactionId = await this._transactionIdPromise; + } catch { + // This means the initial read operation rejected + // and we do not have a transaction ID to roll back + this._transactionIdPromise = undefined; + return; + } - const request: api.IRollbackRequest = { - database: this._firestore.formattedName, - transaction: transactionId, - }; - this._transactionIdPromise = undefined; - this._prevTransactionId = transactionId; - - // We don't need to wait for rollback to completed before continuing. - // If there are any locks held, then rollback will eventually release them. - // Rollback can be done concurrently thereby reducing latency caused by - // otherwise blocking. - this._firestore - .request('rollback', request, this._requestTag) - .catch(err => { - logger( - 'Firestore.runTransaction', - this._requestTag, - 'Best effort to rollback failed with error:', - err - ); - }); + const request: api.IRollbackRequest = { + database: this._firestore.formattedName, + transaction: transactionId, + }; + this._transactionIdPromise = undefined; + this._prevTransactionId = transactionId; + + // We don't need to wait for rollback to completed before continuing. + // If there are any locks held, then rollback will eventually release them. + // Rollback can be done concurrently thereby reducing latency caused by + // otherwise blocking. + this._firestore + .request('rollback', request, this._requestTag) + .catch(err => { + logger( + 'Firestore.runTransaction', + this._requestTag, + 'Best effort to rollback failed with error:', + err + ); + }); + } + ); } /** @@ -542,45 +582,60 @@ export class Transaction implements firestore.Transaction { async runTransaction( updateFunction: (transaction: Transaction) => Promise ): Promise { - // No backoff is set for readonly transactions (i.e. attempts == 1) - if (!this._writeBatch) { - return this.runTransactionOnce(updateFunction); - } - - let lastError: GoogleError | undefined = undefined; - for (let attempt = 0; attempt < this._maxAttempts; ++attempt) { - try { - if (lastError) { - logger( - 'Firestore.runTransaction', - this._requestTag, - 'Retrying transaction after error:', - lastError - ); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_TRANSACTION_RUN, + async span => { + // No backoff is set for readonly transactions (i.e. attempts == 1) + if (!this._writeBatch) { + return this.runTransactionOnce(updateFunction); } - this._writeBatch._reset(); - - await maybeBackoff(this._backoff!, lastError); + let lastError: GoogleError | undefined = undefined; + for (let attempt = 0; attempt < this._maxAttempts; ++attempt) { + span.setAttributes({ + [ATTRIBUTE_KEY_TRANSACTION_TYPE]: this._writeBatch + ? 'READ_WRITE' + : 'READ_ONLY', + [ATTRIBUTE_KEY_ATTEMPTS_ALLOWED]: this._maxAttempts, + [ATTRIBUTE_KEY_ATTEMPTS_REMAINING]: this._maxAttempts - attempt - 1, + }); + + try { + if (lastError) { + logger( + 'Firestore.runTransaction', + this._requestTag, + 'Retrying transaction after error:', + lastError + ); + + span.addEvent('Initiate transaction retry'); + } + + this._writeBatch._reset(); + + await maybeBackoff(this._backoff!, lastError); + + return await this.runTransactionOnce(updateFunction); + } catch (err) { + lastError = err; + + if (!isRetryableTransactionError(err)) { + break; + } + } + } - return await this.runTransactionOnce(updateFunction); - } catch (err) { - lastError = err; + logger( + 'Firestore.runTransaction', + this._requestTag, + 'Transaction not eligible for retry, returning error: %s', + lastError + ); - if (!isRetryableTransactionError(err)) { - break; - } + return Promise.reject(lastError); } - } - - logger( - 'Firestore.runTransaction', - this._requestTag, - 'Transaction not eligible for retry, returning error: %s', - lastError ); - - return Promise.reject(lastError); } /** @@ -716,13 +771,18 @@ export class Transaction implements firestore.Transaction { transaction?: Uint8Array; result: DocumentSnapshot[]; }> { - const documentReader = new DocumentReader( - this._firestore, - documents, - fieldMask, - opts + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_TRANSACTION_GET_DOCUMENTS, + async () => { + const documentReader = new DocumentReader( + this._firestore, + documents, + fieldMask, + opts + ); + return documentReader._get(this._requestTag); + } ); - return documentReader._get(this._requestTag); } private async getQueryFn< diff --git a/dev/src/write-batch.ts b/dev/src/write-batch.ts index 4a139fd26..62fe7d996 100644 --- a/dev/src/write-batch.ts +++ b/dev/src/write-batch.ts @@ -48,6 +48,11 @@ import { import {StatusCode} from './status-code'; import api = google.firestore.v1; +import { + ATTRIBUTE_KEY_DOC_COUNT, + ATTRIBUTE_KEY_IS_TRANSACTIONAL, + SPAN_NAME_BATCH_COMMIT, +} from './telemetry/trace-util'; /** * A WriteResult wraps the write time set by the Firestore servers on sets(), @@ -113,7 +118,7 @@ export type PendingWriteOp = () => api.IWrite; * @class WriteBatch */ export class WriteBatch implements firestore.WriteBatch { - private readonly _firestore: Firestore; + protected readonly _firestore: Firestore; private readonly _serializer: Serializer; private readonly _allowUndefined: boolean; @@ -571,26 +576,35 @@ export class WriteBatch implements firestore.WriteBatch { * ``` */ commit(): Promise { - // Capture the error stack to preserve stack tracing across async calls. - const stack = Error().stack!; - - // Commits should also be retried when they fail with status code ABORTED. - const retryCodes = [StatusCode.ABORTED, ...getRetryCodes('commit')]; - - return this._commit({retryCodes}) - .then(response => { - return (response.writeResults || []).map( - writeResult => - new WriteResult( - Timestamp.fromProto( - writeResult.updateTime || response.commitTime! - ) - ) - ); - }) - .catch(err => { - throw wrapError(err, stack); - }); + return this._firestore._traceUtil.startActiveSpan( + SPAN_NAME_BATCH_COMMIT, + async () => { + // Capture the error stack to preserve stack tracing across async calls. + const stack = Error().stack!; + + // Commits should also be retried when they fail with status code ABORTED. + const retryCodes = [StatusCode.ABORTED, ...getRetryCodes('commit')]; + + return this._commit({retryCodes}) + .then(response => { + return (response.writeResults || []).map( + writeResult => + new WriteResult( + Timestamp.fromProto( + writeResult.updateTime || response.commitTime! + ) + ) + ); + }) + .catch(err => { + throw wrapError(err, stack); + }); + }, + { + [ATTRIBUTE_KEY_IS_TRANSACTIONAL]: false, + [ATTRIBUTE_KEY_DOC_COUNT]: this._opCount, + } + ); } /** diff --git a/dev/system-test/tracing.ts b/dev/system-test/tracing.ts new file mode 100644 index 000000000..473b4a031 --- /dev/null +++ b/dev/system-test/tracing.ts @@ -0,0 +1,660 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as chaiAsPromised from 'chai-as-promised'; +import {expect, use} from 'chai'; +import {describe, it, beforeEach, afterEach} from 'mocha'; +import { + Attributes, + context, + diag, + DiagConsoleLogger, + DiagLogLevel, + trace, + TracerProvider, +} from '@opentelemetry/api'; +import {TraceExporter} from '@google-cloud/opentelemetry-cloud-trace-exporter'; +import {Settings} from '@google-cloud/firestore'; +import { + AlwaysOnSampler, + BatchSpanProcessor, + ConsoleSpanExporter, + InMemorySpanExporter, + NodeTracerProvider, + ReadableSpan, +} from '@opentelemetry/sdk-trace-node'; +import {setLogFunction, Firestore} from '../src'; +import {verifyInstance} from '../test/util/helpers'; +import { + ATTRIBUTE_KEY_DOC_COUNT, + SPAN_NAME_AGGREGATION_QUERY_GET, + SPAN_NAME_BATCH_COMMIT, + SPAN_NAME_BATCH_GET_DOCUMENTS, + SPAN_NAME_BULK_WRITER_COMMIT, + SPAN_NAME_COL_REF_ADD, + SPAN_NAME_COL_REF_LIST_DOCUMENTS, + SPAN_NAME_DOC_REF_CREATE, + SPAN_NAME_DOC_REF_DELETE, + SPAN_NAME_DOC_REF_GET, + SPAN_NAME_DOC_REF_LIST_COLLECTIONS, + SPAN_NAME_DOC_REF_SET, + SPAN_NAME_DOC_REF_UPDATE, + SPAN_NAME_PARTITION_QUERY, + SPAN_NAME_QUERY_GET, + SPAN_NAME_TRANSACTION_COMMIT, + SPAN_NAME_TRANSACTION_GET_AGGREGATION_QUERY, + SPAN_NAME_TRANSACTION_GET_DOCUMENT, + SPAN_NAME_TRANSACTION_GET_DOCUMENTS, + SPAN_NAME_TRANSACTION_GET_QUERY, + SPAN_NAME_TRANSACTION_RUN, +} from '../src/telemetry/trace-util'; +import {AsyncLocalStorageContextManager} from '@opentelemetry/context-async-hooks'; +import {deepStrictEqual} from 'assert'; + +use(chaiAsPromised); + +// Enable OpenTelemetry debug message for local debugging. +diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG); + +// Enable Firestore debug messages for local debugging. +setLogFunction((msg: string) => { + console.log(`LOG: ${msg}`); +}); + +// TODO(tracing): This should be moved to firestore.d.ts when we want to +// release the feature. +export interface FirestoreOpenTelemetryOptions { + enableTracing?: boolean; + tracerProvider?: any; +} + +interface TestConfig { + // In-Memory tests check trace correctness by inspecting traces in memory by + // utilizing InMemorySpanExporter. These tests have `e2e` set to `false`. + // End-to-End tests check trace correctness by querying the Google Cloud Trace + // backend and making sure it has recorded all the expected spans. These + // tests have `e2e` set to `true`. + e2e: boolean; + + // OpenTelemetry allows registering a specific OpenTelemetry instance as the + // "global" instance. We should make sure that the tracing works in two + // circumstances: (1) the user provides us with a specific OpenTelemetry + // instance. (2) the user does not provide an instance and we find the + // global instance to use. + useGlobalOpenTelemetry: boolean; + + // Whether the Firestore instance should use gRPC or REST. + preferRest: boolean; +} + +describe('Tracing Tests', () => { + let firestore: Firestore; + let tracerProvider: NodeTracerProvider; + let inMemorySpanExporter: InMemorySpanExporter; + let consoleSpanExporter: ConsoleSpanExporter; + let gcpTraceExporter: TraceExporter; + + const spanIdToChildrenSpanIds = new Map(); + const spanIdToSpanData = new Map(); + let rootSpanIds: string[] = []; + + function afterEachTest(): Promise { + spanIdToChildrenSpanIds.clear(); + spanIdToSpanData.clear(); + rootSpanIds = []; + + return verifyInstance(firestore); + } + + function getOpenTelemetryOptions( + config: TestConfig, + tracerProvider: TracerProvider + ): FirestoreOpenTelemetryOptions { + const options: FirestoreOpenTelemetryOptions = { + enableTracing: true, + tracerProvider: undefined, + }; + + // If we are *not* using a global OpenTelemetry instance, a TracerProvider + // must be passed to the Firestore SDK. + if (!config.useGlobalOpenTelemetry) { + options.tracerProvider = tracerProvider; + } + + return options; + } + + function beforeEachTest(config: TestConfig) { + // Remove the global tracer provider in case anything was registered + // in order to avoid duplicate global tracers. + trace.disable(); + context.disable(); + + // Set up a context manager. + const contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + + // Create a new tracer and span processor for each test to make sure there + // are no overlaps when reading the results. + tracerProvider = new NodeTracerProvider({ + sampler: new AlwaysOnSampler(), + }); + + inMemorySpanExporter = new InMemorySpanExporter(); + consoleSpanExporter = new ConsoleSpanExporter(); + gcpTraceExporter = new TraceExporter(); + + // Always add the console exporter for local debugging. + tracerProvider.addSpanProcessor( + new BatchSpanProcessor(consoleSpanExporter) + ); + + if (config.e2e) { + tracerProvider.addSpanProcessor(new BatchSpanProcessor(gcpTraceExporter)); + } else { + tracerProvider.addSpanProcessor( + new BatchSpanProcessor(inMemorySpanExporter) + ); + } + + if (config.useGlobalOpenTelemetry) { + trace.setGlobalTracerProvider(tracerProvider); + } + + const settings: Settings = { + preferRest: config.preferRest, + openTelemetryOptions: getOpenTelemetryOptions(config, tracerProvider), + }; + + // Named-database tests use an environment variable to specify the database ID. Add it to the settings. + if (process.env.FIRESTORE_NAMED_DATABASE) { + settings.databaseId = process.env.FIRESTORE_NAMED_DATABASE; + } + // If a database ID has not been specified in the settings, check whether + // it's been specified using an environment variable. + if (!settings.databaseId && process.env.DATABASE_ID) { + settings.databaseId = process.env.DATABASE_ID; + } + // If a Project ID has not been specified in the settings, check whether + // it's been specified using an environment variable. + if (!settings.projectId && process.env.PROJECT_ID) { + settings.projectId = process.env.PROJECT_ID; + } + + firestore = new Firestore(settings); + } + + // Returns true on success, and false otherwise. + async function waitForCompletedInMemorySpans(): Promise { + await tracerProvider.forceFlush(); + await inMemorySpanExporter.forceFlush(); + await new Promise(resolve => setTimeout(resolve, 1000)); + return true; + } + + // Returns true on success, and false otherwise. + async function waitForCompletedCloudTraceSpans( + numExpectedSpans: number + ): Promise { + // TODO(tracing): implement + return false; + } + + async function waitForCompletedSpans( + config: TestConfig, + numExpectedSpans: number + ): Promise { + let success = false; + if (config.e2e) { + success = await waitForCompletedCloudTraceSpans(numExpectedSpans); + } else { + success = await waitForCompletedInMemorySpans(); + } + + if (success) { + buildSpanMaps(config); + } + expect(spanIdToSpanData.size).to.equal( + numExpectedSpans, + `Could not find expected number of spans (${numExpectedSpans})` + ); + } + + function buildSpanMaps(config: TestConfig): void { + if (config.e2e) { + // TODO(tracing): implement + } else { + // Using InMemorySpanExporter. + const spans = inMemorySpanExporter.getFinishedSpans(); + spans.forEach(span => { + const id = getSpanId(span)!; + const parentId = getParentSpanId(span); + if (!parentId) { + rootSpanIds.push(id); + } else { + let children = spanIdToChildrenSpanIds.get(parentId); + // Initialize to empty array if it hasn't been seen before. + if (!children) { + children = []; + } + // Add the new child. + children.push(id); + spanIdToChildrenSpanIds.set(parentId, children); + } + spanIdToSpanData.set(id, span); + }); + } + } + + // Returns the span id of the given span. + function getSpanId(span: ReadableSpan | undefined): string | undefined { + return span?.spanContext().spanId; + } + + // Returns the parent span id of the given span. + function getParentSpanId(span: ReadableSpan | undefined): string | undefined { + return span?.parentSpanId; + } + + // Returns the trace id of the given span. + function getTraceId(span: ReadableSpan | undefined): string | undefined { + return span?.spanContext().traceId; + } + + function getChildSpans(spanId: string): string[] | undefined { + return spanIdToChildrenSpanIds.get(spanId); + } + + // Returns the first span it can find with the given name, or null if it cannot find a span with the given name. + // If there are multiple spans with the same name, it'll return the first one. + function getSpanByName(spanName: string): ReadableSpan | null { + for (const spanData of spanIdToSpanData.values()) { + if (spanData.name === spanName) { + return spanData; + } + } + return null; + } + + // Returns the array of spans that match the given span hierarchy names starting + // at the given root. Returns an empty list if it cannot find such hierarchy under + // the given root. + function dfsSpanHierarchy( + rootSpanId: string, + spanNamesHierarchy: string[] + ): ReadableSpan[] { + // This function returns an empty list if it cannot find a full match. + const notAMatch: ReadableSpan[] = []; + const rootSpan = spanIdToSpanData.get(rootSpanId); + + if (spanNamesHierarchy.length === 0 || !rootSpan) { + return notAMatch; + } + + if (spanIdToSpanData.get(rootSpanId)?.name !== spanNamesHierarchy[0]) { + // The root names didn't match. + return notAMatch; + } else { + // The root names matched. The new hierarchy to match can be obtained by + // popping the first element of `spanNamesHierarchy`. + const newSpanNamesHierarchy = spanNamesHierarchy.slice(1); + + const children = getChildSpans(rootSpanId); + if (!children || children.length === 0) { + if (newSpanNamesHierarchy.length === 0) { + // The root span doesn't have any more children, and there are no + // more span names to match. This is a successful match, and it is + // a base case for the recursion. + return [rootSpan]; + } else { + // The root span doesn't have any more children, but there are still + // more span names to match. + return notAMatch; + } + } else { + // See which (if any) of the child trees matches `newSpanNamesHierarchy`. + for (let childIndex = 0; childIndex < children.length; ++childIndex) { + const newRootSpanId = children[childIndex]; + const subtreeMatch = dfsSpanHierarchy( + newRootSpanId, + newSpanNamesHierarchy + ); + if (subtreeMatch.length > 0) { + // We found a full match in the child tree. + return [rootSpan].concat(subtreeMatch); + } + } + + // If none of the child trees matched `newSpanNamesHierarchy`, we were + // not able to find a full match anywhere in our child trees. + return notAMatch; + } + } + } + + // Asserts that the span hierarchy exists for the given span names. + // The hierarchy starts with the root span, followed by the child span, + // grandchild span, and so on. It also asserts that all the given spans belong + // to the same trace, and that Firestore-generated spans contain the expected + // Firestore attributes. + function expectSpanHierarchy(...spanNamesHierarchy: string[]): void { + expect(spanNamesHierarchy.length).to.be.greaterThan( + 0, + 'The expected spans hierarchy was empty' + ); + + let matchingSpanHierarchy: ReadableSpan[] = []; + + // The Firestore operations that have been executed generate a number of + // spans. The span names, however, are not unique. For example, we could have: + // "DocRef.Get" (id:1) -> "grpc.GET" (id:2) -> "POST" (id:3) + // "DocRef.Set" (id:4) -> "grpc.SET" (id:5) -> "POST" (id:6) + // Note that span names are not unique (e.g. span 3 and span 6). + // Let's say we want to check if the following span hierarchy exists: + // [DocRef.Set -> grpc.SET -> POST] + // We start at each root span (span 1 and span 4 in this case), and check if + // the span hierarchy matches the given `spanNamesHierarchy`. + for (let i = 0; i < rootSpanIds.length; ++i) { + matchingSpanHierarchy = dfsSpanHierarchy( + rootSpanIds[i], + spanNamesHierarchy + ); + if (matchingSpanHierarchy.length > 0) break; + } + + expect(matchingSpanHierarchy.length).to.be.greaterThan( + 0, + `Was not able to find the following span hierarchy: ${spanNamesHierarchy}` + ); + console.log('Found the following span hierarchy:'); + matchingSpanHierarchy.forEach(value => console.log(value.name)); + + for (let i = 0; i + 1 < matchingSpanHierarchy.length; ++i) { + const parentSpan = matchingSpanHierarchy[i]; + const childSpan = matchingSpanHierarchy[i + 1]; + expect(getTraceId(childSpan)).to.equal( + getTraceId(parentSpan), + `'${childSpan.name}' and '${parentSpan.name}' spans do not belong to the same trace` + ); + // TODO(tracing): expect that each span has the needed attributes. + } + } + + // Ensures that the given span exists and has exactly all the given attributes. + function expectSpanHasAttributes(spanName: string, attributes: Attributes) { + // Expect that the span exists first. + const span = getSpanByName(spanName); + expect(span).to.not.be.null; + + // Assert that the attributes are the same. + deepStrictEqual(span!.attributes, attributes); + } + + describe('In-Memory', () => { + describe('Non-Global-OTEL', () => { + describe('GRPC', () => { + const config: TestConfig = { + e2e: false, + useGlobalOpenTelemetry: false, + preferRest: false, + }; + beforeEach(async () => beforeEachTest(config)); + runTestCases(config); + afterEach(async () => afterEachTest()); + }); + describe('REST', () => { + const config: TestConfig = { + e2e: false, + useGlobalOpenTelemetry: false, + preferRest: true, + }; + beforeEach(async () => beforeEachTest(config)); + runTestCases(config); + afterEach(async () => afterEachTest()); + }); + }); + describe('with Global-OTEL', () => { + describe('GRPC', () => { + const config: TestConfig = { + e2e: false, + useGlobalOpenTelemetry: true, + preferRest: false, + }; + beforeEach(async () => beforeEachTest(config)); + runTestCases(config); + afterEach(async () => afterEachTest()); + }); + describe('REST', () => { + const config: TestConfig = { + e2e: false, + useGlobalOpenTelemetry: true, + preferRest: true, + }; + beforeEach(async () => beforeEachTest(config)); + runTestCases(config); + afterEach(async () => afterEachTest()); + }); + }); + }); + + describe.skip('E2E', () => { + describe('Non-Global-OTEL', () => { + describe('GRPC', () => { + const config: TestConfig = { + e2e: true, + useGlobalOpenTelemetry: false, + preferRest: false, + }; + beforeEach(async () => beforeEachTest(config)); + runTestCases(config); + afterEach(async () => afterEachTest()); + }); + describe('REST', () => { + const config: TestConfig = { + e2e: true, + useGlobalOpenTelemetry: false, + preferRest: true, + }; + beforeEach(async () => beforeEachTest(config)); + runTestCases(config); + afterEach(async () => afterEachTest()); + }); + }); + describe('with Global-OTEL', () => { + describe('GRPC', () => { + const config: TestConfig = { + e2e: true, + useGlobalOpenTelemetry: true, + preferRest: false, + }; + beforeEach(async () => beforeEachTest(config)); + runTestCases(config); + afterEach(async () => afterEachTest()); + }); + describe('REST', () => { + const config: TestConfig = { + e2e: true, + useGlobalOpenTelemetry: true, + preferRest: true, + }; + beforeEach(async () => beforeEachTest(config)); + runTestCases(config); + afterEach(async () => afterEachTest()); + }); + }); + }); + + function runTestCases(config: TestConfig) { + it('document reference get()', async () => { + await firestore.collection('foo').doc('bar').get(); + + await waitForCompletedSpans(config, 2); + expectSpanHierarchy(SPAN_NAME_DOC_REF_GET, SPAN_NAME_BATCH_GET_DOCUMENTS); + }); + + it('document reference create()', async () => { + await firestore.collection('foo').doc().create({}); + + await waitForCompletedSpans(config, 2); + expectSpanHierarchy(SPAN_NAME_DOC_REF_CREATE, SPAN_NAME_BATCH_COMMIT); + }); + + it('document reference delete()', async () => { + await firestore.collection('foo').doc('bar').delete(); + + await waitForCompletedSpans(config, 2); + expectSpanHierarchy(SPAN_NAME_DOC_REF_DELETE, SPAN_NAME_BATCH_COMMIT); + }); + + it('document reference set()', async () => { + await firestore.collection('foo').doc('bar').set({foo: 'bar'}); + + await waitForCompletedSpans(config, 2); + expectSpanHierarchy(SPAN_NAME_DOC_REF_SET, SPAN_NAME_BATCH_COMMIT); + }); + + it('document reference update()', async () => { + await firestore.collection('foo').doc('bar').update('foo', 'bar2'); + + await waitForCompletedSpans(config, 2); + expectSpanHierarchy(SPAN_NAME_DOC_REF_UPDATE, SPAN_NAME_BATCH_COMMIT); + }); + + it('document reference list collections', async () => { + await firestore.collection('foo').doc('bar').listCollections(); + + await waitForCompletedSpans(config, 1); + expectSpanHierarchy(SPAN_NAME_DOC_REF_LIST_COLLECTIONS); + }); + + it('aggregate query get()', async () => { + await firestore.collection('foo').count().get(); + + await waitForCompletedSpans(config, 1); + expectSpanHierarchy(SPAN_NAME_AGGREGATION_QUERY_GET); + }); + + it('collection reference add()', async () => { + await firestore.collection('foo').add({foo: 'bar'}); + + await waitForCompletedSpans(config, 3); + expectSpanHierarchy( + SPAN_NAME_COL_REF_ADD, + SPAN_NAME_DOC_REF_CREATE, + SPAN_NAME_BATCH_COMMIT + ); + }); + + it('collection reference list documents', async () => { + await firestore.collection('foo').listDocuments(); + + await waitForCompletedSpans(config, 1); + expectSpanHierarchy(SPAN_NAME_COL_REF_LIST_DOCUMENTS); + }); + + it('query get()', async () => { + await firestore + .collection('foo') + .where('foo', '==', 'bar') + .limit(1) + .get(); + + await waitForCompletedSpans(config, 1); + expectSpanHierarchy(SPAN_NAME_QUERY_GET); + }); + + it('firestore getAll()', async () => { + const docRef1 = firestore.collection('foo').doc('1'); + const docRef2 = firestore.collection('foo').doc('2'); + await firestore.getAll(docRef1, docRef2); + + await waitForCompletedSpans(config, 1); + expectSpanHierarchy(SPAN_NAME_BATCH_GET_DOCUMENTS); + }); + + it('transaction', async () => { + const docRef1 = firestore.collection('foo').doc('bar'); + const docRef2 = firestore.collection('foo').doc('bar'); + + await firestore.runTransaction(async transaction => { + await transaction.get(docRef1); + await transaction.getAll(docRef1, docRef2); + await transaction.get(firestore.collection('foo').limit(1)); + await transaction.get(firestore.collection('nonexistent').count()); + transaction.set(firestore.collection('foo').doc(), {foo: 'bar'}); + }); + + await waitForCompletedSpans(config, 6); + expectSpanHierarchy( + SPAN_NAME_TRANSACTION_RUN, + SPAN_NAME_TRANSACTION_GET_DOCUMENT + ); + expectSpanHierarchy( + SPAN_NAME_TRANSACTION_RUN, + SPAN_NAME_TRANSACTION_GET_DOCUMENTS + ); + expectSpanHierarchy( + SPAN_NAME_TRANSACTION_RUN, + SPAN_NAME_TRANSACTION_GET_QUERY + ); + expectSpanHierarchy( + SPAN_NAME_TRANSACTION_RUN, + SPAN_NAME_TRANSACTION_GET_AGGREGATION_QUERY + ); + expectSpanHierarchy( + SPAN_NAME_TRANSACTION_RUN, + SPAN_NAME_TRANSACTION_COMMIT + ); + }); + + it('batch', async () => { + const writeBatch = firestore.batch(); + const documentRef = firestore.doc('col/doc'); + writeBatch.set(documentRef, {foo: 'bar'}); + await writeBatch.commit(); + + await waitForCompletedSpans(config, 1); + expectSpanHierarchy(SPAN_NAME_BATCH_COMMIT); + }); + + it('partition query', async () => { + const query = firestore.collectionGroup('foo'); + let numPartitions = 0; + for await (const partition of query.getPartitions(3)) { + numPartitions++; + } + + await waitForCompletedSpans(config, 1); + expectSpanHierarchy(SPAN_NAME_PARTITION_QUERY); + }); + + it('bulk writer', async () => { + const bulkWriter = firestore.bulkWriter(); + // No need to await the set operations as 'close()' will commit all writes before closing. + bulkWriter.set(firestore.collection('foo').doc(), {foo: 1}); + bulkWriter.set(firestore.collection('foo').doc(), {foo: 2}); + bulkWriter.set(firestore.collection('foo').doc(), {foo: 3}); + bulkWriter.set(firestore.collection('foo').doc(), {foo: 4}); + bulkWriter.set(firestore.collection('foo').doc(), {foo: 5}); + await bulkWriter.close(); + + await waitForCompletedSpans(config, 1); + expectSpanHierarchy(SPAN_NAME_BULK_WRITER_COMMIT); + expectSpanHasAttributes(SPAN_NAME_BULK_WRITER_COMMIT, { + [ATTRIBUTE_KEY_DOC_COUNT]: 5, + }); + }); + } +}); diff --git a/dev/test/tracing.ts b/dev/test/tracing.ts new file mode 100644 index 000000000..d356fc3fd --- /dev/null +++ b/dev/test/tracing.ts @@ -0,0 +1,148 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {describe, it} from 'mocha'; +import {createInstance} from './util/helpers'; +import {expect} from 'chai'; +import {DisabledTraceUtil} from '../src/telemetry/disabled-trace-util'; +import {EnabledTraceUtil} from '../src/telemetry/enabled-trace-util'; + +describe('Firestore Tracing Controls', () => { + let originalEnvVarValue: string | undefined; + + beforeEach(() => { + originalEnvVarValue = process.env.FIRESTORE_ENABLE_TRACING; + }); + + afterEach(() => { + if (originalEnvVarValue === undefined) { + delete process.env.FIRESTORE_ENABLE_TRACING; + } else { + process.env.FIRESTORE_ENABLE_TRACING = originalEnvVarValue; + } + }); + + it('default firestore settings have tracing disabled', async () => { + const firestore = await createInstance(); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + }); + + it('no openTelemetryOptions results in tracing disabled', async () => { + const firestore = await createInstance(undefined, { + openTelemetryOptions: undefined, + }); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + }); + + it('openTelemetryOptions.enableTracing controls the tracing feature', async () => { + let firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: undefined, + }, + }); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + + firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: false, + }, + }); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + + firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: true, + }, + }); + expect(firestore._traceUtil instanceof EnabledTraceUtil).to.be.true; + }); + + /// Tests to make sure environment variable can override settings. + + it('env var disabled, default firestore settings', async () => { + process.env.FIRESTORE_ENABLE_TRACING = 'OFF'; + const firestore = await createInstance(); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + }); + + it('env var enabled, default firestore settings', async () => { + process.env.FIRESTORE_ENABLE_TRACING = 'ON'; + const firestore = await createInstance(); + expect(firestore._traceUtil instanceof EnabledTraceUtil).to.be.true; + }); + + it('env var disabled, no openTelemetryOptions', async () => { + process.env.FIRESTORE_ENABLE_TRACING = 'OFF'; + const firestore = await createInstance(undefined, { + openTelemetryOptions: undefined, + }); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + }); + + it('env var enabled, no openTelemetryOptions', async () => { + process.env.FIRESTORE_ENABLE_TRACING = 'ON'; + const firestore = await createInstance(undefined, { + openTelemetryOptions: undefined, + }); + expect(firestore._traceUtil instanceof EnabledTraceUtil).to.be.true; + }); + + it('env var disabled, with openTelemetryOptions.enableTracing', async () => { + process.env.FIRESTORE_ENABLE_TRACING = 'OFF'; + let firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: undefined, + }, + }); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + + firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: false, + }, + }); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + + firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: true, + }, + }); + expect(firestore._traceUtil instanceof DisabledTraceUtil).to.be.true; + }); + + it('env var enabled, with openTelemetryOptions.enableTracing', async () => { + process.env.FIRESTORE_ENABLE_TRACING = 'ON'; + let firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: undefined, + }, + }); + expect(firestore._traceUtil instanceof EnabledTraceUtil).to.be.true; + + firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: false, + }, + }); + expect(firestore._traceUtil instanceof EnabledTraceUtil).to.be.true; + + firestore = await createInstance(undefined, { + openTelemetryOptions: { + enableTracing: true, + }, + }); + expect(firestore._traceUtil instanceof EnabledTraceUtil).to.be.true; + }); +}); diff --git a/package.json b/package.json index aed88c640..290c2d844 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "precompile": "gts clean" }, "dependencies": { + "@opentelemetry/api": "^1.3.0", "fast-deep-equal": "^3.1.1", "functional-red-black-tree": "^1.0.1", "google-gax": "^4.3.3", @@ -70,6 +71,9 @@ }, "devDependencies": { "@google-cloud/cloud-rad": "^0.4.0", + "@opentelemetry/sdk-trace-node": "^1.24.1", + "@opentelemetry/context-async-hooks": "^1.24.1", + "@google-cloud/opentelemetry-cloud-trace-exporter": "^2.0.0", "@types/assert": "^1.4.0", "@types/chai": "^4.2.7", "@types/chai-as-promised": "^7.1.2",