Skip to main content

haste_health/
main.rs

1use std::{
2    collections::HashMap,
3    path::PathBuf,
4    sync::{Arc, LazyLock},
5    time::Duration,
6};
7
8use clap::{Parser, Subcommand};
9use haste_config::{Config, ConfigType, get_config};
10use haste_fhir_model::r4::generated::terminology::IssueType;
11use haste_fhir_operation_error::OperationOutcomeError;
12use haste_server::auth_n::oidc::routes::discovery::WellKnownDiscoveryDocument;
13use opentelemetry::KeyValue;
14use opentelemetry::trace::TracerProvider as _;
15use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
16use opentelemetry_otlp::WithExportConfig;
17use opentelemetry_otlp::{Protocol, WithHttpConfig};
18use opentelemetry_sdk::Resource;
19use opentelemetry_sdk::logs::SdkLoggerProvider;
20use opentelemetry_sdk::trace::SdkTracerProvider;
21use reqwest::Url;
22use tokio::sync::Mutex;
23use tracing_subscriber::registry::LookupSpan;
24use tracing_subscriber::{EnvFilter, layer::SubscriberExt, registry::Registry};
25use tracing_tree::HierarchicalLayer;
26
27use crate::commands::config::{CLIConfiguration, load_config};
28
29mod client;
30mod commands;
31
32#[derive(Parser)]
33#[command(version, about, long_about = None)] // Read from `Cargo.toml`
34struct Cli {
35    #[command(subcommand)]
36    command: CLICommand,
37}
38
39#[derive(Subcommand)]
40enum CLICommand {
41    /// Data gets pulled from stdin.
42    FHIRPath {
43        /// lists test values
44        fhirpath: String,
45    },
46    Generate {
47        /// Input FHIR StructureDefinition file (JSON)
48        #[command(subcommand)]
49        command: commands::codegen::CodeGen,
50    },
51    Server {
52        #[command(subcommand)]
53        command: commands::server::ServerCommands,
54    },
55    Api {
56        #[command(subcommand)]
57        command: commands::api::ApiCommands,
58    },
59    Config {
60        #[command(subcommand)]
61        command: commands::config::ConfigCommands,
62    },
63    Worker {
64        #[command(subcommand)]
65        command: Option<commands::worker::WorkerCommands>,
66    },
67    Testscript {
68        #[command(subcommand)]
69        command: commands::testscript::TestScriptCommands,
70    },
71    Admin {
72        #[command(subcommand)]
73        command: commands::admin::AdminCommands,
74    },
75    Hl7v2 {
76        #[command(subcommand)]
77        command: commands::hl7v2::HL7v2Commands,
78    },
79}
80
81static CONFIG_LOCATION: LazyLock<PathBuf> = LazyLock::new(|| {
82    let config_dir = std::env::home_dir()
83        .unwrap_or_else(|| std::path::PathBuf::from("."))
84        .join(".haste_health");
85
86    std::fs::create_dir_all(&config_dir).expect("Failed to create config directory");
87
88    config_dir.join("config.toml")
89});
90
91pub struct CLIState {
92    config: CLIConfiguration,
93    access_token: Option<String>,
94    well_known_document: Option<WellKnownDiscoveryDocument>,
95}
96
97impl CLIState {
98    pub fn new(config: CLIConfiguration) -> Self {
99        CLIState {
100            config,
101            access_token: None,
102            well_known_document: None,
103        }
104    }
105}
106
107static CLI_STATE: LazyLock<Arc<Mutex<CLIState>>> = LazyLock::new(|| {
108    let config = load_config(&CONFIG_LOCATION);
109
110    Arc::new(Mutex::new(CLIState::new(config)))
111});
112
113enum CLIEnvironmentVariables {
114    SentryDSN,
115    OTELEndpoint,
116    OTELHeaders,
117    LogType,
118}
119
120impl From<CLIEnvironmentVariables> for String {
121    fn from(value: CLIEnvironmentVariables) -> Self {
122        match value {
123            CLIEnvironmentVariables::SentryDSN => "SENTRY_DSN".to_string(),
124            CLIEnvironmentVariables::OTELEndpoint => "OTEL_ENDPOINT".to_string(),
125            CLIEnvironmentVariables::OTELHeaders => "OTEL_HEADERS".to_string(),
126            CLIEnvironmentVariables::LogType => "LOG_TYPE".to_string(),
127        }
128    }
129}
130
131struct OtelGuard {
132    _tracer_provider: SdkTracerProvider,
133    _logger_provider: SdkLoggerProvider,
134}
135
136fn otel_guard(config: &dyn Config<CLIEnvironmentVariables>) -> Option<OtelGuard> {
137    let endpoint = config.get(CLIEnvironmentVariables::OTELEndpoint).ok()?;
138    let headers_str = config
139        .get(CLIEnvironmentVariables::OTELHeaders)
140        .unwrap_or_default();
141    let headers = headers_str
142        .split(',')
143        .filter_map(|pair| {
144            let mut parts = pair.splitn(2, '=');
145            if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
146                Some((key.trim().to_string(), value.trim().to_string()))
147            } else {
148                None
149            }
150        })
151        .collect::<HashMap<String, String>>();
152
153    let root_otel_endpoint = Url::parse(&endpoint).expect("Invalid OTLP endpoint URL");
154
155    // See https://opentelemetry.io/docs/specs/otlp/#otlphttp-request
156    // v1/traces for spans, v1/logs for logs
157    let mut trace_endpoint = root_otel_endpoint.clone();
158    trace_endpoint
159        .path_segments_mut()
160        .expect("OTEL endpoint cannot be a base URL")
161        .extend(&["v1", "traces"]);
162
163    let mut log_endpoint = root_otel_endpoint.clone();
164    log_endpoint
165        .path_segments_mut()
166        .expect("OTEL endpoint cannot be a base URL")
167        .extend(&["v1", "logs"]);
168
169    let oltp_span_exporter = opentelemetry_otlp::SpanExporter::builder()
170        .with_http()
171        .with_protocol(Protocol::HttpBinary)
172        .with_endpoint(trace_endpoint.as_str())
173        .with_headers(headers.clone())
174        .with_timeout(Duration::from_secs(5))
175        .build()
176        .expect("Failed to create OpenTelemetry span exporter");
177
178    let oltp_log_exporter = opentelemetry_otlp::LogExporter::builder()
179        .with_http()
180        .with_protocol(Protocol::HttpBinary)
181        .with_endpoint(log_endpoint.as_str())
182        .with_headers(headers)
183        .with_timeout(Duration::from_secs(5))
184        .build()
185        .expect("Failed to create OpenTelemetry log exporter");
186
187    let resource = Resource::builder()
188        .with_attribute(KeyValue::new("service.name", "haste-health"))
189        .build();
190
191    let tracer_provider = SdkTracerProvider::builder()
192        .with_resource(resource.clone())
193        .with_batch_exporter(oltp_span_exporter)
194        .build();
195
196    let logger_provider = SdkLoggerProvider::builder()
197        .with_resource(resource)
198        .with_batch_exporter(oltp_log_exporter)
199        .build();
200
201    opentelemetry::global::set_tracer_provider(tracer_provider.clone());
202
203    Some(OtelGuard {
204        _tracer_provider: tracer_provider,
205        _logger_provider: logger_provider,
206    })
207}
208
209fn inject_otel_subscriber<S>(
210    subscriber: S,
211    config: &dyn Config<CLIEnvironmentVariables>,
212) -> Option<OtelGuard>
213where
214    S: tracing::Subscriber + Send + Sync + 'static,
215    for<'span> S: LookupSpan<'span>,
216{
217    if let Some(guard) = otel_guard(config) {
218        let tracer = guard._tracer_provider.tracer("haste-health");
219        let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
220        let otel_logs = OpenTelemetryTracingBridge::new(&guard._logger_provider);
221
222        tracing::subscriber::set_global_default(subscriber.with(telemetry).with(otel_logs))
223            .unwrap();
224
225        Some(guard)
226    } else {
227        tracing::subscriber::set_global_default(subscriber).unwrap();
228        None
229    }
230}
231
232fn setup_tracing(
233    config: &dyn Config<CLIEnvironmentVariables>,
234) -> Result<Option<OtelGuard>, OperationOutcomeError> {
235    let log_type = config
236        .get(CLIEnvironmentVariables::LogType)
237        .unwrap_or("JSON".to_string());
238
239    let subscriber = Registry::default()
240        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")));
241
242    match log_type.as_str() {
243        "TREE" => Ok(inject_otel_subscriber(
244            subscriber.with(HierarchicalLayer::new(2)),
245            config,
246        )),
247        "JSON" => Ok(inject_otel_subscriber(
248            subscriber.with(tracing_subscriber::fmt::Layer::default().json()),
249            config,
250        )),
251        _ => Err(OperationOutcomeError::fatal(
252            IssueType::Invalid(None),
253            "Invalid log type specified in environment variable LOG_TYPE. Supported values are 'TREE' and 'JSON'.".to_string(),
254        )),
255    }
256}
257
258fn main() -> Result<(), OperationOutcomeError> {
259    let config = get_config(ConfigType::Environment);
260
261    let cli = Cli::parse();
262    let cli_state = CLI_STATE.clone();
263
264    let sentry_location = config.get(CLIEnvironmentVariables::SentryDSN);
265
266    let _guard = sentry::init((
267        sentry_location.unwrap_or_default(),
268        sentry::ClientOptions {
269            release: sentry::release_name!(),
270            // Capture user IPs and potentially sensitive headers when using HTTP server integrations
271            // see https://docs.sentry.io/platforms/rust/data-management/data-collected for more info
272            send_default_pii: true,
273            ..Default::default()
274        },
275    ));
276
277    tokio::runtime::Builder::new_multi_thread()
278        .enable_all()
279        // 8MB stack size
280        .thread_stack_size(1024 * 8000)
281        .build()
282        .unwrap()
283        .block_on(async {
284            let _otel_provider = setup_tracing(config.as_ref())?;
285            match &cli.command {
286                CLICommand::FHIRPath { fhirpath } => commands::fhirpath::fhirpath(fhirpath).await,
287                CLICommand::Generate { command } => commands::codegen::codegen(command).await,
288                CLICommand::Server { command } => commands::server::server(command).await,
289                CLICommand::Worker { command } => commands::worker::worker(command).await,
290                CLICommand::Config { command } => {
291                    commands::config::config(&cli_state, command).await
292                }
293                CLICommand::Api { command } => {
294                    commands::api::api_commands(cli_state, command).await
295                }
296                CLICommand::Testscript { command } => {
297                    commands::testscript::testscript_commands(cli_state, command).await
298                }
299                CLICommand::Admin { command } => commands::admin::admin(command).await,
300                CLICommand::Hl7v2 { command } => commands::hl7v2::hl7v2(cli_state, command).await,
301            }
302        })
303}