Skip to content

Commit f950fc4

Browse files
authored
fix: stream monitoring (#573)
1 parent 729bbdf commit f950fc4

File tree

7 files changed

+119
-128
lines changed

7 files changed

+119
-128
lines changed

src/internal/concurrency/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
export * from './mutex'
2-
export * from './stream'
32
export * from './async-abort-controller'

src/internal/monitoring/otel.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { S3Store } from '@tus/s3-store'
3434
import { Upload } from '@aws-sdk/lib-storage'
3535
import { StreamSplitter } from '@tus/server'
3636
import { PgLock } from '@storage/protocols/tus'
37+
import { Semaphore, Permit } from '@shopify/semaphore'
3738

3839
const tracingEnabled = process.env.TRACING_ENABLED === 'true'
3940
const headersEnv = process.env.OTEL_EXPORTER_OTLP_TRACES_HEADERS || ''
@@ -265,6 +266,16 @@ const sdk = new NodeSDK({
265266
enabled: true,
266267
methodsToInstrument: ['lock', 'unlock', 'acquireLock'],
267268
}),
269+
new ClassInstrumentation({
270+
targetClass: Semaphore,
271+
enabled: true,
272+
methodsToInstrument: ['acquire'],
273+
}),
274+
new ClassInstrumentation({
275+
targetClass: Permit,
276+
enabled: true,
277+
methodsToInstrument: ['release'],
278+
}),
268279
new ClassInstrumentation({
269280
targetClass: S3Client,
270281
enabled: true,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Transform, TransformCallback } from 'stream'
1+
import { Transform, TransformCallback } from 'node:stream'
22

33
export const createByteCounterStream = () => {
44
let bytes = 0

src/internal/streams/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './stream-speed'
2+
export * from './byte-counter'
3+
export * from './monitor'

src/internal/streams/monitor.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { createByteCounterStream } from './byte-counter'
2+
import { monitorStreamSpeed } from './stream-speed'
3+
import { trace } from '@opentelemetry/api'
4+
import { Readable } from 'node:stream'
5+
6+
/**
7+
* Monitor readable streams by tracking their speed and bytes read
8+
* @param dataStream
9+
*/
10+
export function monitorStream(dataStream: Readable) {
11+
const speedMonitor = monitorStreamSpeed(dataStream)
12+
const byteCounter = createByteCounterStream()
13+
14+
let measures: number[] = []
15+
16+
// Handle the 'speed' event to collect speed measurements
17+
speedMonitor.on('speed', (bps) => {
18+
measures.push(bps)
19+
const span = trace.getActiveSpan()
20+
span?.setAttributes({ 'stream.speed': measures, bytesRead: byteCounter.bytes })
21+
})
22+
23+
speedMonitor.on('close', () => {
24+
measures = []
25+
const span = trace.getActiveSpan()
26+
span?.setAttributes({ uploadRead: byteCounter.bytes })
27+
})
28+
29+
// Handle errors by cleaning up and destroying the downstream stream
30+
speedMonitor.on('error', (err) => {
31+
// Destroy the byte counter stream with the error
32+
byteCounter.transformStream.destroy(err)
33+
})
34+
35+
// Ensure the byteCounter stream ends when speedMonitor ends
36+
speedMonitor.on('end', () => {
37+
byteCounter.transformStream.end()
38+
})
39+
40+
// Return the piped stream
41+
return speedMonitor.pipe(byteCounter.transformStream)
42+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Readable } from 'stream'
2+
import { PassThrough } from 'node:stream'
3+
4+
/**
5+
* Keep track of a stream's speed
6+
* @param stream
7+
* @param frequency
8+
*/
9+
/**
10+
* Keep track of a stream's speed
11+
* @param stream
12+
* @param frequency
13+
*/
14+
export function monitorStreamSpeed(stream: Readable, frequency = 1000) {
15+
let totalBytes = 0
16+
const startTime = Date.now()
17+
18+
const passThrough = new PassThrough()
19+
20+
const interval = setInterval(() => {
21+
const currentTime = Date.now()
22+
const elapsedTime = (currentTime - startTime) / 1000
23+
const currentSpeedBytesPerSecond = totalBytes / elapsedTime
24+
25+
passThrough.emit('speed', currentSpeedBytesPerSecond)
26+
}, frequency)
27+
28+
passThrough.on('data', (chunk) => {
29+
totalBytes += chunk.length
30+
})
31+
32+
const cleanup = () => {
33+
clearInterval(interval)
34+
passThrough.removeAllListeners('speed')
35+
}
36+
37+
// Handle close event to ensure cleanup
38+
passThrough.on('close', cleanup)
39+
40+
// Propagate errors from the source stream to the passThrough
41+
stream.on('error', (err) => {
42+
passThrough.destroy(err)
43+
})
44+
45+
// Ensure the passThrough ends when the source stream ends
46+
stream.on('end', () => {
47+
passThrough.end()
48+
})
49+
50+
return stream.pipe(passThrough)
51+
}

src/storage/backend/s3.ts

Lines changed: 11 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -27,27 +27,12 @@ import {
2727
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
2828
import { ERRORS, StorageBackendError } from '@internal/errors'
2929
import { getConfig } from '../../config'
30-
import { addAbortSignal, PassThrough, Readable } from 'node:stream'
31-
import { trace } from '@opentelemetry/api'
32-
import { createByteCounterStream } from '@internal/concurrency'
33-
import { AgentStats, createAgent, gatherHttpAgentStats, InstrumentedAgent } from '@internal/http'
30+
import { Readable } from 'node:stream'
31+
import { createAgent, InstrumentedAgent } from '@internal/http'
32+
import { monitorStream } from '@internal/streams'
3433

3534
const { tracingFeatures, storageS3MaxSockets, tracingEnabled } = getConfig()
3635

37-
interface StreamStatus {
38-
time: Date
39-
bytesUploaded: number
40-
progress: Progress[]
41-
dataStream: {
42-
closed: boolean
43-
paused: boolean
44-
errored: boolean
45-
writable: boolean
46-
byteRead: number
47-
}
48-
httpAgentStats: AgentStats
49-
}
50-
5136
export interface S3ClientOptions {
5237
endpoint?: string
5338
region?: string
@@ -154,22 +139,19 @@ export class S3Backend implements StorageBackendAdapter {
154139
throw ERRORS.Aborted('Upload was aborted')
155140
}
156141

157-
const streamWatcher = tracingFeatures?.upload ? this.watchUploadStream(body, signal) : undefined
158-
const uploadStream = streamWatcher ? streamWatcher.dataStream : body
142+
const dataStream = tracingFeatures?.upload ? monitorStream(body) : body
159143

160144
const upload = new Upload({
161145
client: this.client,
162146
params: {
163147
Bucket: bucketName,
164148
Key: withOptionalVersion(key, version),
165-
Body: uploadStream,
149+
Body: dataStream,
166150
ContentType: contentType,
167151
CacheControl: cacheControl,
168152
},
169153
})
170154

171-
streamWatcher?.watchUpload(upload)
172-
173155
signal?.addEventListener(
174156
'abort',
175157
() => {
@@ -178,6 +160,12 @@ export class S3Backend implements StorageBackendAdapter {
178160
{ once: true }
179161
)
180162

163+
if (tracingFeatures?.upload) {
164+
upload.on('httpUploadProgress', (progress: Progress) => {
165+
dataStream.emit('s3_progress', JSON.stringify(progress))
166+
})
167+
}
168+
181169
try {
182170
const data = await upload.done()
183171
const metadata = await this.headObject(bucketName, key, version)
@@ -194,26 +182,9 @@ export class S3Backend implements StorageBackendAdapter {
194182
}
195183
} catch (err) {
196184
if (err instanceof Error && err.name === 'AbortError') {
197-
const span = trace.getActiveSpan()
198-
if (span) {
199-
// Print how far we got uploading the file
200-
const lastSeenStatus = streamWatcher?.lastSeenStreamStatus
201-
const lastStreamStatus = streamWatcher?.getStreamStatus()
202-
203-
if (lastSeenStatus && lastStreamStatus) {
204-
const { progress, ...lastSeenStream } = lastSeenStatus
205-
span.setAttributes({
206-
lastStreamStatus: JSON.stringify(lastStreamStatus),
207-
lastSeenStatus: JSON.stringify(lastSeenStream),
208-
})
209-
}
210-
}
211-
212185
throw ERRORS.AbortedTerminate('Upload was aborted', err)
213186
}
214187
throw StorageBackendError.fromError(err)
215-
} finally {
216-
streamWatcher?.stop()
217188
}
218189
}
219190

@@ -493,92 +464,6 @@ export class S3Backend implements StorageBackendAdapter {
493464
this.agent.close()
494465
}
495466

496-
protected watchUploadStream(body: Readable, signal?: AbortSignal) {
497-
const passThrough = new PassThrough()
498-
499-
if (signal) {
500-
addAbortSignal(signal, passThrough)
501-
}
502-
503-
passThrough.on('error', () => {
504-
body.unpipe(passThrough)
505-
})
506-
507-
body.on('error', (err) => {
508-
if (!passThrough.closed) {
509-
passThrough.destroy(err)
510-
}
511-
})
512-
513-
const byteReader = createByteCounterStream()
514-
const bodyStream = body.pipe(passThrough)
515-
516-
// Upload stats
517-
const uploadProgress: Progress[] = []
518-
const getStreamStatus = (): StreamStatus => ({
519-
time: new Date(),
520-
bytesUploaded: uploadProgress[uploadProgress.length - 1]?.loaded || 0,
521-
dataStream: {
522-
closed: bodyStream.closed,
523-
paused: bodyStream.isPaused(),
524-
errored: Boolean(bodyStream.errored),
525-
writable: bodyStream.writable,
526-
byteRead: byteReader.bytes,
527-
},
528-
httpAgentStats: gatherHttpAgentStats(this.agent.httpsAgent.getCurrentStatus()),
529-
progress: uploadProgress,
530-
})
531-
532-
let streamStatus = getStreamStatus()
533-
534-
const streamWatcher = setInterval(() => {
535-
streamStatus = getStreamStatus()
536-
}, 1000)
537-
538-
const dataStream = passThrough.pipe(byteReader.transformStream)
539-
540-
body.on('error', (err) => {
541-
passThrough.destroy(err)
542-
})
543-
544-
passThrough.on('error', (err) => {
545-
body.destroy(err)
546-
})
547-
548-
passThrough.on('close', () => {
549-
body.unpipe(passThrough)
550-
})
551-
552-
function watchUpload(upload: Upload) {
553-
upload.on('httpUploadProgress', (progress) => {
554-
uploadProgress.push({
555-
total: progress.total,
556-
part: progress.part,
557-
loaded: progress.loaded,
558-
})
559-
if (uploadProgress.length > 100) {
560-
uploadProgress.shift()
561-
}
562-
})
563-
}
564-
565-
return {
566-
dataStream,
567-
byteReader,
568-
get uploadProgress() {
569-
return uploadProgress
570-
},
571-
get lastSeenStreamStatus() {
572-
return streamStatus
573-
},
574-
getStreamStatus,
575-
stop() {
576-
clearInterval(streamWatcher)
577-
},
578-
watchUpload,
579-
}
580-
}
581-
582467
protected createS3Client(options: S3ClientOptions & { name: string }) {
583468
const params: S3ClientConfig = {
584469
region: options.region,

0 commit comments

Comments
 (0)