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",